import { createHash } from "node:crypto"; import type { WorkspaceConnectionTemplateUse } from "../connections/catalog.js"; import { describeCredentialScopeGap, resolveCredential, type CredentialContext, } from "../credentials/index.js"; import { createSsrfSafeDispatcher, isBlockedExtensionUrlWithDns, } from "../extensions/url-safety.js"; import { processWebContent, type WebContentSearchOptions, type WebExtractMode, type WebResponseMode, } from "../extensions/web-content.js"; import { deleteOAuthTokens, listOAuthAccountsByOwner, saveOAuthTokens, } from "../oauth-tokens/index.js"; import { resolveSecret } from "../server/credential-provider.js"; import { resolveGoogleProviderCredentialCandidates } from "../server/google-oauth-credentials.js"; import { getCredentialContext } from "../server/request-context.js"; import { mergeDefinitionsById } from "../shared/merge-by-id.js"; import { resolveWorkspaceConnectionCredentialForApp } from "../workspace-connections/credentials.js"; import { resolveWorkspaceConnectionForApp } from "../workspace-connections/store.js"; import type { CustomProviderConfig, CustomProviderAuthKind, } from "./custom-registry.js"; import { createProviderQuotaIdentity, createProviderRequestDedupeKey, executeWithProviderQuota, type ProviderQuotaIdentity, type ProviderQuotaExhaustedDetail, } from "./quota-governor.js"; export type { CustomProviderConfig, CustomProviderScope, CustomProviderAuthKind, UpsertCustomProviderArgs, } from "./custom-registry.js"; export { upsertCustomProvider, deleteCustomProvider, listCustomProviders, getCustomProvider, validateCustomBaseUrl, assertCanMutateCustomProviderScope, CustomProviderAuthError, } from "./custom-registry.js"; export const PROVIDER_API_IDS = [ "amplitude", "apollo", "bigquery", "clay", "commonroom", "dataforseo", "ga4", "gcloud", "github", "figma", "gmail", "gong", "google_calendar", "google_drive", "google_slides", "granola", "grafana", "hubspot", "salesforce", "jira", "mixpanel", "notion", "posthog", "prometheus", "pylon", "sentry", "slack", "stripe", "twitter", ] as const; export type ProviderApiId = (typeof PROVIDER_API_IDS)[number]; export type ProviderApiMethod = | "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; /** Cursor-pagination config for fetchAllPages. */ export interface FetchAllPagesConfig { /** * Dot-path into the JSON response body where the next-page cursor lives, * e.g. "meta.next_cursor" or "pagination.next_page_token". */ cursorPath: string; /** * Query parameter name to pass the cursor on the next request, * e.g. "cursor" or "page_token". */ cursorParam?: string; /** * Dot-path in the JSON request body to set to the cursor on the next request. * Use this for POST-body pagination, e.g. Gong's top-level `cursor`. */ cursorBodyPath?: string; /** * Dot-path to the items array in each response body. * When omitted, the whole response body is appended to the items array. */ itemsPath?: string; /** * Maximum number of pages to fetch. Default 10, max 50. */ maxPages?: number; } export interface ProviderApiRequestArgs { provider: ProviderApiId | string; method?: ProviderApiMethod; path: string; query?: unknown; headers?: Record; body?: unknown; auth?: "default" | "none"; timeoutMs?: number; maxBytes?: number; connectionId?: string | null; accountId?: string | null; /** * When set, write the full response body to this workspace file path instead * of returning it in context. Returns a compact summary with status, bytes, * path, and a preview. Allows up to 20 MB (vs the normal 4 MB context limit). */ saveToFile?: string; /** * When set, automatically paginate by cursor until the cursor field is empty * or maxPages is reached. Accumulates items from itemsPath (or whole bodies) * across all pages. Combine with saveToFile to write the full dataset. */ fetchAllPages?: FetchAllPagesConfig; } export interface GitHubRepositoryRef { owner: string; repo: string; } export interface GitHubRepositoryFileEntry { name: string; path: string; type: "file" | "dir" | "symlink" | "submodule" | "unknown"; sha: string | null; size: number | null; url: string | null; htmlUrl: string | null; downloadUrl: string | null; } export interface GitHubRepositoryFileListArgs extends GitHubRepositoryRef { path?: string; ref?: string; recursive?: boolean; includeDirectories?: boolean; maxFiles?: number; connectionId?: string | null; timeoutMs?: number; maxBytes?: number; } export interface GitHubRepositoryFileSearchArgs extends GitHubRepositoryRef { query?: string; path?: string; filename?: string; extension?: string; language?: string; ref?: string; perPage?: number; page?: number; maxFiles?: number; includeTextMatches?: boolean; connectionId?: string | null; timeoutMs?: number; maxBytes?: number; } export interface GitHubRepositoryFileReadArgs extends GitHubRepositoryRef { path: string; ref?: string; connectionId?: string | null; timeoutMs?: number; maxBytes?: number; } export interface GitHubRepositoryFileWriteArgs extends GitHubRepositoryRef { path: string; content: string; message: string; branch?: string; sha?: string; overwriteExisting?: boolean; committer?: GitHubRepositoryCommitIdentity; author?: GitHubRepositoryCommitIdentity; connectionId?: string | null; timeoutMs?: number; maxBytes?: number; } export interface GitHubRepositoryFileDeleteArgs extends GitHubRepositoryRef { path: string; message: string; branch?: string; sha?: string; committer?: GitHubRepositoryCommitIdentity; author?: GitHubRepositoryCommitIdentity; connectionId?: string | null; timeoutMs?: number; maxBytes?: number; } export interface GitHubRepositoryCommitIdentity { name: string; email: string; date?: string; } export interface GitHubRepositoryFileListResult { repository: GitHubRepositoryRef; ref: string | null; path: string; recursive: boolean; entries: GitHubRepositoryFileEntry[]; totalCount: number; truncated: boolean; providerTruncated: boolean; response: Pick; } export interface GitHubRepositoryFileSearchResult { repository: GitHubRepositoryRef; mode: "code-search" | "tree-filter"; query: string | null; ref: string | null; items: GitHubRepositoryFileEntry[]; totalCount: number; truncated: boolean; incompleteResults: boolean; response: Pick; } export interface GitHubRepositoryFileReadResult { repository: GitHubRepositoryRef; path: string; ref: string | null; name: string; type: string; sha: string; size: number; encoding: string | null; content: string | null; contentBase64: string | null; url: string | null; htmlUrl: string | null; downloadUrl: string | null; response: Pick; } export interface GitHubRepositoryFileWriteResult { repository: GitHubRepositoryRef; path: string; branch: string | null; content: { name: string | null; path: string | null; sha: string | null; url: string | null; htmlUrl: string | null; }; commit: { sha: string | null; url: string | null; htmlUrl: string | null; message: string | null; }; response: Pick; } export interface GitHubRepositoryFileDeleteResult { repository: GitHubRepositoryRef; path: string; branch: string | null; commit: { sha: string | null; url: string | null; htmlUrl: string | null; message: string | null; }; response: Pick; } export interface ProviderApiDocsOptions { provider: ProviderApiId | string; url?: string; maxBytes?: number; maxChars?: number; responseMode?: WebResponseMode; extract?: WebExtractMode; includeLinks?: boolean; search?: WebContentSearchOptions; } export type ProviderApiAuthKind = | { type: "none" } | { type: "bearer"; keys: readonly string[]; workspaceProvider?: string; } | { type: "basic"; usernameKey: string; passwordKey: string; workspaceProvider?: string; } | { type: "basic-raw"; key: string; workspaceProvider?: string; } | { type: "api-key-header"; key: string; header: string; workspaceProvider?: string; } | { type: "google-service-account"; scopes: readonly string[]; } | { type: "oauth-bearer"; oauthProvider: string; tokenLabel: string; workspaceProvider?: string; } | { type: "oauth-bearer-or-api-key-header"; oauthProvider: string; tokenLabel: string; key: string; fallbackKeys?: readonly string[]; header: string; workspaceProvider: string; } | { type: "oauth-bearer-or-bearer-key"; oauthProvider: string; tokenLabel: string; key: string; fallbackKeys?: readonly string[]; workspaceProvider: string; } | { type: "oauth-bearer-or-basic"; oauthProvider: string; tokenLabel: string; usernameKey: string; passwordKey: string; workspaceProvider: string; } | { type: "prometheus"; }; export interface ProviderApiConfig { id: ProviderApiId; label: string; defaultBaseUrl: string; requiresConnectionId?: boolean; baseUrlCredentialKey?: string; auth: ProviderApiAuthKind; credentialKeys: readonly string[]; docsUrls: readonly string[]; specUrls?: readonly string[]; allowedHostSuffixes?: readonly string[]; defaultHeaders?: Record; placeholders?: readonly ProviderApiPlaceholder[]; examples?: readonly ProviderApiExample[]; notes?: readonly string[]; accessErrorGuidance?: string; corpusRecipes?: readonly ProviderApiCorpusRecipe[]; templateUses?: readonly WorkspaceConnectionTemplateUse[]; } export interface ProviderApiPlaceholder { name: string; credentialKey: string; label: string; } export interface ProviderApiExample { label: string; method: ProviderApiMethod; path: string; query?: unknown; body?: unknown; } export interface ProviderApiCorpusRecipe { label: string; useWhen: string; workflow: readonly string[]; request: { method: ProviderApiMethod; path: string; body?: unknown; query?: unknown; }; pagination?: { itemsPath?: string; nextCursorPath?: string; cursorParam?: string; cursorBodyPath?: string; pageParam?: string; offsetParam?: string; pageSize?: number; maxPages?: number; }; batch?: { inputValuePath?: string; itemBodyPath?: string; itemQueryParam?: string; responseItemsPath?: string; batchSize?: number; }; search?: { textPaths?: readonly string[]; idPaths?: readonly string[]; metadataPaths?: readonly string[]; }; } export interface ProviderApiResolvedCredential { key: string; value: string; source: string; provider: string; connectionId?: string; connectionLabel?: string; accountId?: string; accountLabel?: string | null; scope?: string; } export interface ProviderApiCredentialLookupOptions { appId: string; provider: string; key: string; ctx: CredentialContext; workspaceProvider?: string; connectionId?: string | null; localCredentialSource: string; } export type ProviderApiCredentialResolver = ( options: ProviderApiCredentialLookupOptions, ) => Promise; export interface ProviderApiRuntimeOptions { appId: string; providerIds?: readonly (ProviderApiId | string)[]; /** App-owned definitions replace matching built-ins by id without dropping other providers. */ providerOverrides?: readonly ProviderApiConfig[]; localCredentialSource?: string; getCredentialContext?: () => CredentialContext | null; resolveCredential?: ProviderApiCredentialResolver; /** * Template-specific OAuth token provider overrides for built-in provider API * configs. Use when an app stores a provider's OAuth grant under a narrower * local provider id, e.g. Google Drive scoped to a "google-docs" connection. */ oauthProviderOverrides?: Record; /** * Optional loader for custom providers registered at runtime. When provided, * custom providers are merged with the static built-in registry for catalog, * docs, and request operations. Custom providers cannot shadow built-in ids. */ getCustomProviders?: () => Promise; } export interface ProviderApiRuntime { providerIds: readonly ProviderApiId[]; listCatalog( provider?: ProviderApiId | string, ): ReturnType | Promise; fetchDocs(options: ProviderApiDocsOptions): Promise; executeRequest(args: ProviderApiRequestArgs): Promise; resolveOAuthAccessToken(args: { provider: ProviderApiId; connectionId?: string | null; accountId?: string | null; }): Promise; listGitHubRepositoryFiles( args: GitHubRepositoryFileListArgs, ): Promise; searchGitHubRepositoryFiles( args: GitHubRepositoryFileSearchArgs, ): Promise; readGitHubRepositoryFile( args: GitHubRepositoryFileReadArgs, ): Promise; writeGitHubRepositoryFile( args: GitHubRepositoryFileWriteArgs, ): Promise; deleteGitHubRepositoryFile( args: GitHubRepositoryFileDeleteArgs, ): Promise; } export interface ProviderApiOAuthAccessToken { accessToken: string; accountId: string | null; accountLabel: string | null; connectionId: string | null; connectionLabel: string | null; } interface ResolvedAuth { headers: Record; credentialSources: Array>; secretValues: string[]; } interface ProviderApiHttpResponse { status: number; statusText: string; ok: boolean; elapsedMs: number; headers: Record; contentType: string | null; size: number; truncated: boolean; text?: string; json?: unknown; quota?: { exhausted: boolean; providerId: string; retryAfterMs: number; retryAt: string; reason: string; }; } interface ProviderApiFetchQuotaOptions { identity: ProviderQuotaIdentity; method: ProviderApiMethod; target: string; requestKey?: string; } interface OAuthTokens { access_token?: string; accessToken?: string; refresh_token?: string; refreshToken?: string; expiry_date?: number; expiresAt?: number; expires_in?: number; token_type?: string; scope?: string; } const PERMANENT_GOOGLE_OAUTH_REFRESH_ERRORS = new Set([ "invalid_grant", "unauthorized_client", "invalid_client", ]); const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 120_000; const DEFAULT_MAX_BYTES = 1024 * 1024; const MAX_MAX_BYTES = 4 * 1024 * 1024; /** When saveToFile is used, allow a much larger per-page response since the * content won't enter the model's context window. */ const SAVE_TO_FILE_MAX_BYTES = 20 * 1024 * 1024; // 20 MB const FETCH_ALL_PAGES_MAX = 50; const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const BLOCKED_OUTBOUND_HEADERS = new Set([ "connection", "content-length", "cookie", "forwarded", "host", "keep-alive", "origin", "proxy-authenticate", "proxy-authorization", "referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", ]); const PROVIDER_CONFIGS: Record = { amplitude: { id: "amplitude", label: "Amplitude", defaultBaseUrl: "https://amplitude.com/api/2", auth: { type: "basic", usernameKey: "AMPLITUDE_API_KEY", passwordKey: "AMPLITUDE_SECRET_KEY", }, credentialKeys: ["AMPLITUDE_API_KEY", "AMPLITUDE_SECRET_KEY"], docsUrls: ["https://amplitude.com/docs/apis"], allowedHostSuffixes: ["amplitude.com"], templateUses: ["analytics"], examples: [ { label: "Export events", method: "GET", path: "/export?start=20260601T00&end=20260602T00", }, ], }, apollo: { id: "apollo", label: "Apollo", defaultBaseUrl: "https://api.apollo.io", auth: { type: "api-key-header", key: "APOLLO_API_KEY", header: "x-api-key", }, credentialKeys: ["APOLLO_API_KEY"], docsUrls: ["https://docs.apollo.io/reference/api-reference"], templateUses: ["analytics", "calendar"], examples: [ { label: "Search people", method: "POST", path: "/api/v1/mixed_people/search", body: { q_keywords: "vp marketing", page: 1, per_page: 10 }, }, ], }, bigquery: { id: "bigquery", label: "BigQuery REST API", defaultBaseUrl: "https://bigquery.googleapis.com/bigquery/v2", auth: { type: "google-service-account", scopes: [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/bigquery", ], }, credentialKeys: [ "GOOGLE_APPLICATION_CREDENTIALS_JSON", "BIGQUERY_PROJECT_ID", ], docsUrls: ["https://cloud.google.com/bigquery/docs/reference/rest"], specUrls: ["https://bigquery.googleapis.com/$discovery/rest?version=v2"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["analytics"], placeholders: [ { name: "projectId", credentialKey: "BIGQUERY_PROJECT_ID", label: "Configured BigQuery project ID", }, ], examples: [ { label: "List datasets", method: "GET", path: "/projects/{projectId}/datasets", }, { label: "Run query", method: "POST", path: "/projects/{projectId}/queries", body: { query: "SELECT 1", useLegacySql: false }, }, ], }, clay: { id: "clay", label: "Clay Public API", defaultBaseUrl: "https://api.clay.com/public/v0", auth: { type: "api-key-header", key: "CLAY_PUBLIC_API_KEY", header: "clay-api-key", }, credentialKeys: ["CLAY_PUBLIC_API_KEY"], docsUrls: [ "https://developers.clay.com/llms.txt", "https://developers.clay.com/public-api/authentication", "https://developers.clay.com/concepts/execution-model.md", ], specUrls: ["https://developers.clay.com/openapi.json"], templateUses: ["analytics"], examples: [ { label: "Get authenticated user and workspace", method: "GET", path: "/me", }, { label: "List people search filter fields", method: "GET", path: "/search/filters-mode/fields", query: { source_type: "people" }, }, { label: "Create a people search", method: "POST", path: "/search/filters-mode", body: { source_type: "people", filters: { job_title_keywords: ["software engineer"], location_cities_include: ["New York"], }, }, }, ], notes: [ "Clay is a GTM provider API capability, not a messaging channel.", "The Public API key is tied to a Clay user and workspace. It is separate from the optional local clay CLI/MCP session created by clay login; hosted Agent Native access does not require that plugin session.", "Search results use a stateful forward-only iterator: repeat POST /search/filters-mode/{search_id}/run while has_more is true.", "The Tables API is query-only, requires an Enterprise plan, and needs a known table id; the Public API does not list, create, or update tables.", "Routine runs are asynchronous. Start a run, then poll the documented results endpoint or use a verified completion webhook.", ], }, commonroom: { id: "commonroom", label: "Common Room", defaultBaseUrl: "https://api.commonroom.io/community/v1", auth: { type: "bearer", keys: ["COMMONROOM_API_TOKEN"], }, credentialKeys: ["COMMONROOM_API_TOKEN"], docsUrls: ["https://developer.commonroom.io/reference/overview"], templateUses: ["analytics"], examples: [{ label: "List members", method: "GET", path: "/members" }], }, dataforseo: { id: "dataforseo", label: "DataForSEO", defaultBaseUrl: "https://api.dataforseo.com/v3", auth: { type: "basic", usernameKey: "DATAFORSEO_LOGIN", passwordKey: "DATAFORSEO_PASSWORD", }, credentialKeys: ["DATAFORSEO_LOGIN", "DATAFORSEO_PASSWORD"], docsUrls: ["https://docs.dataforseo.com/v3/"], templateUses: ["analytics"], examples: [ { label: "SERP task post", method: "POST", path: "/serp/google/organic/task_post", body: [ { keyword: "builder.io", location_code: 2840, language_code: "en" }, ], }, ], }, ga4: { id: "ga4", label: "Google Analytics Data API", defaultBaseUrl: "https://analyticsdata.googleapis.com/v1beta", auth: { type: "google-service-account", scopes: ["https://www.googleapis.com/auth/analytics.readonly"], }, credentialKeys: ["GOOGLE_APPLICATION_CREDENTIALS_JSON", "GA4_PROPERTY_ID"], docsUrls: [ "https://developers.google.com/analytics/devguides/reporting/data/v1/rest", ], specUrls: [ "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta", ], allowedHostSuffixes: ["googleapis.com"], templateUses: ["analytics"], placeholders: [ { name: "propertyId", credentialKey: "GA4_PROPERTY_ID", label: "Configured GA4 property ID", }, ], examples: [ { label: "Run report", method: "POST", path: "/properties/{propertyId}:runReport", body: { dateRanges: [{ startDate: "30daysAgo", endDate: "today" }], metrics: [{ name: "activeUsers" }], }, }, ], }, gcloud: { id: "gcloud", label: "Google Cloud APIs", defaultBaseUrl: "https://cloudresourcemanager.googleapis.com", auth: { type: "google-service-account", scopes: [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/monitoring.read", "https://www.googleapis.com/auth/logging.read", "https://www.googleapis.com/auth/bigquery", ], }, credentialKeys: [ "GOOGLE_APPLICATION_CREDENTIALS_JSON", "BIGQUERY_PROJECT_ID", ], docsUrls: ["https://cloud.google.com/apis/docs/overview"], specUrls: ["https://www.googleapis.com/discovery/v1/apis"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["analytics"], placeholders: [ { name: "projectId", credentialKey: "BIGQUERY_PROJECT_ID", label: "Configured Google Cloud project ID", }, ], examples: [ { label: "Get project", method: "GET", path: "https://cloudresourcemanager.googleapis.com/v1/projects/{projectId}", }, ], }, github: { id: "github", label: "GitHub REST API", defaultBaseUrl: "https://api.github.com", auth: { type: "oauth-bearer-or-bearer-key", oauthProvider: "github", tokenLabel: "GitHub OAuth token", key: "GITHUB_TOKEN", workspaceProvider: "github", }, credentialKeys: ["GITHUB_TOKEN"], docsUrls: ["https://docs.github.com/rest"], specUrls: [ "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", ], defaultHeaders: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, templateUses: ["analytics", "brain", "design", "dispatch"], examples: [ { label: "Authenticated user", method: "GET", path: "/user" }, { label: "Search issues", method: "GET", path: "/search/issues" }, ], }, figma: { id: "figma", label: "Figma REST API", defaultBaseUrl: "https://api.figma.com/v1", auth: { type: "oauth-bearer-or-api-key-header", oauthProvider: "figma", tokenLabel: "Figma OAuth token", key: "FIGMA_ACCESS_TOKEN", header: "X-Figma-Token", workspaceProvider: "figma", }, credentialKeys: ["FIGMA_ACCESS_TOKEN"], docsUrls: [ "https://developers.figma.com/docs/rest-api/", "https://developers.figma.com/docs/rest-api/scopes/", "https://developers.figma.com/docs/rest-api/file-endpoints/", ], allowedHostSuffixes: ["figma.com"], templateUses: ["design"], examples: [ { label: "Get file document", method: "GET", path: "/files/{fileKey}", }, { label: "Get exact file nodes", method: "GET", path: "/files/{fileKey}/nodes", query: { ids: "1:2" }, }, { label: "List file components", method: "GET", path: "/files/{fileKey}/components", }, { label: "Render file nodes", method: "GET", path: "/images/{fileKey}", query: { ids: "1:2", format: "svg" }, }, ], notes: [ "Personal access tokens should include current_user:read for validation and file_content:read for file/node imports. Add library_content:read for file libraries, team_library_content:read for team libraries, or library_assets:read for individual published assets only when needed.", "Enterprise Variables endpoints require file_variables:read; writes additionally require file_variables:write, a Full seat, and edit access.", "The REST API cannot create arbitrary canvas frames or layers. Use Figma's official OAuth MCP write tools when connected, or export a Design selection as Figma-compatible SVG for visual handoff.", ], }, gmail: { id: "gmail", label: "Gmail API", defaultBaseUrl: "https://gmail.googleapis.com/gmail/v1", auth: { type: "oauth-bearer", oauthProvider: "google", tokenLabel: "Google OAuth token", }, credentialKeys: ["GOOGLE_OAUTH_ACCOUNT"], docsUrls: ["https://developers.google.com/gmail/api/reference/rest"], specUrls: ["https://gmail.googleapis.com/$discovery/rest?version=v1"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["brain", "mail", "dispatch"], examples: [ { label: "List messages", method: "GET", path: "/users/me/messages", }, { label: "Search messages", method: "GET", path: "/users/me/messages", body: undefined, }, ], notes: [ "Uses the current user's stored Google OAuth account. Pass accountId when the user has multiple Google accounts connected.", ], }, gong: { id: "gong", label: "Gong", defaultBaseUrl: "https://api.gong.io/v2", baseUrlCredentialKey: "GONG_API_BASE", auth: { type: "basic", usernameKey: "GONG_ACCESS_KEY", passwordKey: "GONG_ACCESS_SECRET", }, credentialKeys: ["GONG_ACCESS_KEY", "GONG_ACCESS_SECRET", "GONG_API_BASE"], docsUrls: ["https://gong.app.gong.io/settings/api/documentation"], templateUses: ["analytics", "calendar"], examples: [ { label: "List calls", method: "GET", path: "/calls" }, { label: "Call transcript", method: "POST", path: "/calls/transcript", body: { filter: { callIds: [""] } }, }, { label: "Calls with parties/content", method: "POST", path: "/calls/extensive", body: { filter: { fromDateTime: "" }, contentSelector: { exposedFields: { parties: true, content: { brief: true, keyPoints: true }, }, }, }, }, ], notes: [ "For broad corpus work, call /calls/extensive with provider-api-request and stageAs/saveToFile. Gong returns the next cursor at records.cursor and expects the next cursor in the POST body at cursor, so use pagination { nextCursorPath: 'records.cursor', cursorBodyPath: 'cursor' } for stageAs or fetchAllPages { cursorPath: 'records.cursor', cursorBodyPath: 'cursor' } for saveToFile.", "Batch transcripts with POST /calls/transcript and body { filter: { callIds: [...] } } after narrowing or staging call ids.", ], corpusRecipes: [ { label: "Batch-search Gong call transcripts from staged call ids", useWhen: "Use when the user asks to search, count, or prove absence across Gong transcript text for a bounded cohort of call ids.", workflow: [ "Stage or otherwise collect the exact call ids in scope first.", "Start provider-corpus-job with mode=batch-search against /calls/transcript.", "Inject each batch into body path filter.callIds and search responseItemsPath callTranscripts.", "Search transcript.sentence text fields, then report call-id coverage, batches processed, matches, and gaps.", ], request: { method: "POST", path: "/calls/transcript", body: { filter: { callIds: [] } }, }, batch: { inputValuePath: "id", itemBodyPath: "filter.callIds", responseItemsPath: "callTranscripts", batchSize: 20, }, search: { textPaths: ["transcript.sentences.text", "transcript"], idPaths: ["callId"], metadataPaths: ["callId"], }, }, { label: "Stage Gong calls with parties/content", useWhen: "Use when the user needs a complete Gong call cohort before a transcript/message join or coverage-sensitive search.", workflow: [ "Call provider-api-request with stageAs and pagination.", "Use /calls/extensive when party fields or content summaries are needed; use /calls for lightweight metadata.", "Do not treat staged call metadata as transcript text.", ], request: { method: "POST", path: "/calls/extensive", body: { filter: { fromDateTime: "" }, contentSelector: { exposedFields: { parties: true } }, }, }, pagination: { itemsPath: "calls", nextCursorPath: "records.cursor", cursorBodyPath: "cursor", maxPages: 200, }, }, ], }, google_calendar: { id: "google_calendar", label: "Google Calendar API", defaultBaseUrl: "https://www.googleapis.com/calendar/v3", auth: { type: "oauth-bearer", oauthProvider: "google", tokenLabel: "Google OAuth token", }, credentialKeys: ["GOOGLE_OAUTH_ACCOUNT"], docsUrls: ["https://developers.google.com/calendar/api/v3/reference"], specUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["brain", "calendar", "dispatch", "mail"], examples: [ { label: "List calendars", method: "GET", path: "/users/me/calendarList", }, { label: "Search events", method: "GET", path: "/calendars/primary/events", }, ], notes: [ "Uses the current user's stored Google OAuth account. Pass accountId when the user has multiple Google accounts connected.", ], }, google_drive: { id: "google_drive", label: "Google Drive API", defaultBaseUrl: "https://www.googleapis.com/drive/v3", auth: { type: "oauth-bearer", oauthProvider: "google", tokenLabel: "Google OAuth token", workspaceProvider: "google_drive", }, credentialKeys: ["GOOGLE_OAUTH_ACCOUNT"], docsUrls: ["https://developers.google.com/drive/api/reference/rest/v3"], specUrls: ["https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["brain", "content", "slides", "dispatch", "analytics"], examples: [ { label: "List files", method: "GET", path: "/files" }, { label: "Get file metadata", method: "GET", path: "/files/{fileId}" }, ], notes: [ "Uses the current user's stored Google OAuth account. Pass accountId when the user has multiple Google accounts connected.", ], }, google_slides: { id: "google_slides", label: "Google Slides API", defaultBaseUrl: "https://slides.googleapis.com/v1", auth: { type: "oauth-bearer", oauthProvider: "google", tokenLabel: "Google OAuth token", workspaceProvider: "google_drive", }, credentialKeys: ["GOOGLE_OAUTH_ACCOUNT"], docsUrls: [ "https://developers.google.com/workspace/slides/api/reference/rest", ], specUrls: ["https://slides.googleapis.com/$discovery/rest?version=v1"], allowedHostSuffixes: ["googleapis.com"], templateUses: ["brain", "content", "slides", "dispatch"], examples: [ { label: "Get presentation with slides and speaker notes", method: "GET", path: "/presentations/{presentationId}", }, ], notes: [ "Creative context imports use the non-sensitive drive.file scope. A user must choose each presentation through Google Picker before the Slides API can read it; unrestricted Drive-wide discovery is intentionally unavailable.", "Uses the current user's stored Google OAuth account. Pass accountId when the user has multiple Google accounts connected.", ], }, granola: { id: "granola", label: "Granola Public API", defaultBaseUrl: "https://public-api.granola.ai/v1", auth: { type: "bearer", keys: ["GRANOLA_API_KEY"], workspaceProvider: "granola", }, credentialKeys: ["GRANOLA_API_KEY"], docsUrls: ["https://docs.granola.ai/"], templateUses: ["brain", "dispatch"], examples: [ { label: "List notes", method: "GET", path: "/notes" }, { label: "Get note", method: "GET", path: "/notes/" }, ], }, grafana: { id: "grafana", label: "Grafana", defaultBaseUrl: "https://grafana.example.com", baseUrlCredentialKey: "GRAFANA_URL", auth: { type: "bearer", keys: ["GRAFANA_API_TOKEN"], }, credentialKeys: ["GRAFANA_URL", "GRAFANA_API_TOKEN"], docsUrls: ["https://grafana.com/docs/grafana/latest/developers/http_api/"], templateUses: ["analytics"], examples: [ { label: "List dashboards", method: "GET", path: "/api/search" }, ], }, hubspot: { id: "hubspot", label: "HubSpot", defaultBaseUrl: "https://api.hubapi.com", auth: { type: "oauth-bearer-or-bearer-key", oauthProvider: "hubspot", tokenLabel: "HubSpot OAuth token", key: "HUBSPOT_PRIVATE_APP_TOKEN", fallbackKeys: ["HUBSPOT_PRIVATE_APP_TOKEN", "HUBSPOT_ACCESS_TOKEN"], workspaceProvider: "hubspot", }, credentialKeys: ["HUBSPOT_PRIVATE_APP_TOKEN", "HUBSPOT_ACCESS_TOKEN"], docsUrls: ["https://developers.hubspot.com/docs/api/overview"], templateUses: ["analytics", "brain", "calendar", "mail", "dispatch"], examples: [ { label: "Search deals with any HubSpot CRM filter", method: "POST", path: "/crm/v3/objects/deals/search", body: { filterGroups: [ { filters: [ { propertyName: "products", operator: "CONTAINS_TOKEN", value: "Publish", }, ], }, ], properties: ["dealname", "products", "dealstage", "closedate"], limit: 100, }, }, { label: "List deal property metadata", method: "GET", path: "/crm/v3/properties/deals", }, ], }, salesforce: { id: "salesforce", label: "Salesforce", defaultBaseUrl: "https://login.salesforce.com", requiresConnectionId: true, auth: { type: "oauth-bearer", oauthProvider: "salesforce", tokenLabel: "Salesforce OAuth token", workspaceProvider: "salesforce", }, credentialKeys: ["SALESFORCE_OAUTH_ACCOUNT"], docsUrls: [ "https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/", ], allowedHostSuffixes: [], templateUses: ["analytics", "brain", "crm", "dispatch"], examples: [ { label: "List recent Accounts", method: "GET", path: "/services/data/v60.0/sobjects/Account", }, { label: "Query Opportunity records with SOQL", method: "GET", path: "/services/data/v60.0/query", query: { q: "SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunity LIMIT 100", }, }, ], notes: [ "Workspace OAuth stores the Salesforce instance URL on the connection. Requests with a connectionId use that instance instead of the login host.", "CRM templates own object allow-lists, field projections, and API-version selection.", ], }, jira: { id: "jira", label: "Jira Cloud", defaultBaseUrl: "https://example.atlassian.net", baseUrlCredentialKey: "JIRA_BASE_URL", auth: { type: "oauth-bearer-or-basic", oauthProvider: "jira", tokenLabel: "Jira", usernameKey: "JIRA_USER_EMAIL", passwordKey: "JIRA_API_TOKEN", workspaceProvider: "jira", }, credentialKeys: ["JIRA_BASE_URL", "JIRA_USER_EMAIL", "JIRA_API_TOKEN"], docsUrls: [ "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", ], specUrls: [ "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", ], templateUses: ["analytics"], examples: [ { label: "JQL search", method: "GET", path: "/rest/api/3/search/jql", }, ], }, mixpanel: { id: "mixpanel", label: "Mixpanel", defaultBaseUrl: "https://mixpanel.com/api/query", auth: { type: "basic-raw", key: "MIXPANEL_SERVICE_ACCOUNT", }, credentialKeys: ["MIXPANEL_PROJECT_ID", "MIXPANEL_SERVICE_ACCOUNT"], docsUrls: ["https://developer.mixpanel.com/reference/overview"], allowedHostSuffixes: ["mixpanel.com"], templateUses: ["analytics"], placeholders: [ { name: "projectId", credentialKey: "MIXPANEL_PROJECT_ID", label: "Configured Mixpanel project ID", }, ], examples: [ { label: "Query events", method: "GET", path: "/events", }, ], notes: [ "Mixpanel uses multiple API hosts. You may pass full URLs for mixpanel.com or data.mixpanel.com endpoints.", ], }, notion: { id: "notion", label: "Notion", defaultBaseUrl: "https://api.notion.com/v1", auth: { type: "oauth-bearer-or-bearer-key", oauthProvider: "notion", tokenLabel: "Notion OAuth token", key: "NOTION_API_KEY", workspaceProvider: "notion", }, credentialKeys: ["NOTION_API_KEY"], docsUrls: ["https://developers.notion.com/reference/intro"], defaultHeaders: { "Notion-Version": "2026-03-11" }, templateUses: ["analytics", "brain", "content", "dispatch"], examples: [{ label: "Search", method: "POST", path: "/search", body: {} }], notes: [ "The name GET /users/me returns is the label whoever minted the token typed into Notion, so it often names an unrelated product or team. Call it the Notion-side integration label and never restate it as the workspace, app, or product the user is currently in.", ], accessErrorGuidance: "Notion 401/403/404 on a specific page or database usually means that object was never shared with the integration, not that the id is wrong or the key is invalid. Fix: open the page or database in Notion, then ••• → Connections → add the integration. When naming the integration, describe it as the Notion-side integration label from GET /users/me and note that it may not match this app or workspace.", corpusRecipes: [ { label: "Search a Notion data source with complete cursor coverage", useWhen: "Use for coverage-sensitive searches across one known Notion data source's page properties. This does not search page block bodies.", workflow: [ "Resolve the exact data source id and apply the narrowest provider-side filter and filter_properties projection first.", "Start provider-corpus-job with mode=paginated-search and pass next_cursor back as start_cursor in the POST body.", "Search only the returned properties and report the 10,000-result API ceiling plus any incomplete request_status.", "For page body text, stage page ids and fetch block children through a separate bounded data program; property search is not body search.", ], request: { method: "POST", path: "/data_sources//query", body: { page_size: 100 }, }, pagination: { itemsPath: "results", nextCursorPath: "next_cursor", cursorBodyPath: "start_cursor", maxPages: 100, }, search: { textPaths: ["properties"], idPaths: ["id"], metadataPaths: ["id", "url", "created_time", "last_edited_time"], }, }, ], }, posthog: { id: "posthog", label: "PostHog", defaultBaseUrl: "https://app.posthog.com", baseUrlCredentialKey: "POSTHOG_HOST", auth: { type: "bearer", keys: ["POSTHOG_API_KEY"], }, credentialKeys: ["POSTHOG_API_KEY", "POSTHOG_PROJECT_ID", "POSTHOG_HOST"], docsUrls: ["https://posthog.com/docs/api"], templateUses: ["analytics"], placeholders: [ { name: "projectId", credentialKey: "POSTHOG_PROJECT_ID", label: "Configured PostHog project ID", }, ], examples: [ { label: "Aggregate events with HogQL", method: "POST", path: "/api/projects/{projectId}/query/", body: { query: { kind: "HogQLQuery", query: "SELECT event, count() AS events FROM events WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY event ORDER BY events DESC LIMIT 100", }, }, }, ], notes: [ "Prefer the query endpoint with a bounded HogQL projection or server-side aggregation over paging raw events into the agent context.", ], }, prometheus: { id: "prometheus", label: "Prometheus", defaultBaseUrl: "https://prometheus.example.com", baseUrlCredentialKey: "PROMETHEUS_URL", auth: { type: "prometheus" }, credentialKeys: [ "PROMETHEUS_URL", "PROMETHEUS_USERNAME", "PROMETHEUS_PASSWORD", "PROMETHEUS_BEARER_TOKEN", ], docsUrls: ["https://prometheus.io/docs/prometheus/latest/querying/api/"], templateUses: ["analytics"], examples: [ { label: "Instant query", method: "GET", path: "/api/v1/query", }, ], }, pylon: { id: "pylon", label: "Pylon", defaultBaseUrl: "https://api.usepylon.com", auth: { type: "bearer", keys: ["PYLON_API_KEY"], }, credentialKeys: ["PYLON_API_KEY"], docsUrls: ["https://docs.usepylon.com/pylon-docs/developer/api-reference"], templateUses: ["analytics", "calendar"], examples: [{ label: "List issues", method: "GET", path: "/issues" }], }, sentry: { id: "sentry", label: "Sentry", defaultBaseUrl: "https://sentry.io/api/0", auth: { type: "oauth-bearer-or-bearer-key", oauthProvider: "sentry", tokenLabel: "Sentry OAuth token", key: "SENTRY_AUTH_TOKEN", fallbackKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_SERVER_TOKEN"], workspaceProvider: "sentry", }, credentialKeys: [ "SENTRY_AUTH_TOKEN", "SENTRY_SERVER_TOKEN", "SENTRY_ORG_SLUG", ], docsUrls: ["https://docs.sentry.io/api/"], templateUses: ["analytics"], placeholders: [ { name: "orgSlug", credentialKey: "SENTRY_ORG_SLUG", label: "Configured Sentry organization slug", }, ], examples: [ { label: "List issues for org", method: "GET", path: "/organizations/{orgSlug}/issues/", }, ], }, slack: { id: "slack", label: "Slack Web API", defaultBaseUrl: "https://slack.com/api", auth: { type: "bearer", keys: ["SLACK_BOT_TOKEN"], workspaceProvider: "slack", }, credentialKeys: ["SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN_2"], docsUrls: ["https://api.slack.com/web"], specUrls: [ "https://api.slack.com/specs/openapi/v2/slack_web_openapi_v2_without_examples.json", ], templateUses: ["analytics", "brain", "dispatch"], examples: [ { label: "Search messages", method: "GET", path: "/search.messages" }, { label: "Post message", method: "POST", path: "/chat.postMessage" }, ], notes: [ "Bot-token reads must not call conversations.join implicitly. A bot can read only conversations it is already a member of with the required history scope.", "search.messages requires a compatible user token; do not assume a bot token can use it. Use conversations.history for channel-scoped bot reads.", ], corpusRecipes: [ { label: "Search complete Slack channel history", useWhen: "Use for all-message, mention, phrase, or absence-sensitive searches in one known Slack conversation without one agent turn per cursor page.", workflow: [ "Resolve the exact channel id without joining or mutating membership.", "Start provider-corpus-job with mode=paginated-search against conversations.history, bounded by oldest/latest when possible.", "Pass response_metadata.next_cursor back as cursor and let quota_wait pause/resume rate-limited installations.", "Report channel id, time bounds, pages, messages searched, matches, and is_limited or access gaps.", ], request: { method: "GET", path: "/conversations.history", query: { channel: "" }, }, pagination: { itemsPath: "messages", nextCursorPath: "response_metadata.next_cursor", cursorParam: "cursor", maxPages: 500, }, search: { textPaths: ["text", "blocks", "attachments"], idPaths: ["ts", "client_msg_id"], metadataPaths: ["ts", "user", "thread_ts", "type", "subtype"], }, }, ], }, stripe: { id: "stripe", label: "Stripe", defaultBaseUrl: "https://api.stripe.com/v1", auth: { type: "bearer", keys: ["STRIPE_SECRET_KEY"], }, credentialKeys: ["STRIPE_SECRET_KEY"], docsUrls: ["https://docs.stripe.com/api"], specUrls: [ "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", ], templateUses: ["analytics"], examples: [{ label: "List customers", method: "GET", path: "/customers" }], }, twitter: { id: "twitter", label: "Twitter/X via twitterapi.io", defaultBaseUrl: "https://api.twitterapi.io", auth: { type: "api-key-header", key: "TWITTER_BEARER_TOKEN", header: "X-API-Key", }, credentialKeys: ["TWITTER_BEARER_TOKEN"], docsUrls: ["https://twitterapi.io/docs"], templateUses: ["analytics"], examples: [ { label: "User tweets", method: "GET", path: "/twitter/user/last_tweets", }, ], }, }; export function getProviderApiConfig( provider: ProviderApiId | string, overrides: readonly ProviderApiConfig[] = [], ): ProviderApiConfig { const config = mergeProviderApiConfigs(overrides).find( (candidate) => candidate.id === provider, ); if (!config) throw new Error(`Unsupported provider API: ${provider}`); return config; } export function mergeProviderApiConfigs( overrides: readonly ProviderApiConfig[] = [], ): ProviderApiConfig[] { for (const override of overrides) { if (!isProviderApiId(override.id)) { throw new Error( `Provider API overrides must replace a built-in id: ${override.id}`, ); } } return mergeDefinitionsById(Object.values(PROVIDER_CONFIGS), overrides); } export function isProviderApiId(provider: string): provider is ProviderApiId { return Object.prototype.hasOwnProperty.call(PROVIDER_CONFIGS, provider); } export function listProviderApiIdsForTemplateUse( templateUse: WorkspaceConnectionTemplateUse, overrides: readonly ProviderApiConfig[] = [], ): ProviderApiId[] { const configs = new Map( mergeProviderApiConfigs(overrides).map((config) => [config.id, config]), ); return PROVIDER_API_IDS.filter((id) => (configs.get(id)?.templateUses ?? []).includes(templateUse), ); } export function listProviderApiCatalog( provider?: ProviderApiId | string, options: { providerIds?: readonly (ProviderApiId | string)[]; providerOverrides?: readonly ProviderApiConfig[]; } = {}, ) { const providerIds = normalizeProviderIds(options.providerIds); if (provider) { assertProviderAllowed(provider, providerIds); } const configs = provider ? [getProviderApiConfig(provider, options.providerOverrides)] : providerIds.map((id) => getProviderApiConfig(id, options.providerOverrides), ); return configs.map((config) => ({ id: config.id, label: config.label, defaultBaseUrl: config.defaultBaseUrl, requiresConnectionId: config.requiresConnectionId ?? false, baseUrlCredentialKey: config.baseUrlCredentialKey ?? null, auth: describeAuth(config.auth), credentialKeys: config.credentialKeys, docsUrls: config.docsUrls, specUrls: config.specUrls ?? [], allowedHostSuffixes: config.allowedHostSuffixes ?? [], placeholders: config.placeholders ?? [], defaultHeaders: config.defaultHeaders ?? {}, examples: config.examples ?? [], notes: config.notes ?? [], accessErrorGuidance: config.accessErrorGuidance ?? null, corpusRecipes: config.corpusRecipes ?? [], templateUses: config.templateUses ?? [], })); } export function createProviderApiRuntime( options: ProviderApiRuntimeOptions, ): ProviderApiRuntime { mergeProviderApiConfigs(options.providerOverrides); const providerIds = normalizeProviderIds(options.providerIds); const runtimeOptions: Required< Pick > & Omit = { ...options, providerIds, localCredentialSource: options.localCredentialSource ?? "app_local", }; return { providerIds, listCatalog: (provider) => listProviderApiCatalogWithCustom( provider, { providerIds, providerOverrides: options.providerOverrides }, runtimeOptions, ), fetchDocs: (docsOptions) => fetchProviderApiDocs(docsOptions, runtimeOptions), executeRequest: (args) => executeProviderApiRequest(args, runtimeOptions), resolveOAuthAccessToken: (args) => resolveProviderApiOAuthAccessToken(args, runtimeOptions), listGitHubRepositoryFiles: (args) => listGitHubRepositoryFiles(args, runtimeOptions), searchGitHubRepositoryFiles: (args) => searchGitHubRepositoryFiles(args, runtimeOptions), readGitHubRepositoryFile: (args) => readGitHubRepositoryFile(args, runtimeOptions), writeGitHubRepositoryFile: (args) => writeGitHubRepositoryFile(args, runtimeOptions), deleteGitHubRepositoryFile: (args) => deleteGitHubRepositoryFile(args, runtimeOptions), }; } export async function fetchProviderApiDocs( options: ProviderApiDocsOptions, runtime: ProviderApiRuntimeOptions = { appId: "app" }, ) { await assertProviderAllowedAsync(options.provider, runtime); // Resolve config — may be a built-in or a custom provider. const builtIn = isProviderApiId(options.provider) ? getProviderApiConfig(options.provider, runtime.providerOverrides) : null; const customConfig = builtIn ? null : await resolveCustomProvider(options.provider, runtime); if (!builtIn && !customConfig) { const known = await listAllProviderIds(runtime); throw new Error( `Unknown provider "${options.provider}". Known providers: ${known.join(", ")}`, ); } const catalog = builtIn ? listProviderApiCatalog(options.provider, { providerOverrides: runtime.providerOverrides, })[0] : customProviderToCatalogEntry(customConfig!); if (!options.url) { return { provider: options.provider, catalog, guidance: "provider-api-docs can fetch ANY public http(s) URL — pass url to retrieve API documentation, OpenAPI specs, changelogs, or any public web page. Registered docsUrls above are curated starting points.", }; } // Open docs fetching: allow ANY public https/http URL. // The SSRF guard still applies — private/internal addresses are blocked. let url: URL; try { url = new URL(options.url); } catch { throw new Error(`Invalid docs URL: ${options.url}`); } if (url.protocol !== "https:" && url.protocol !== "http:") { throw new Error(`Docs URL must use https: or http: (got ${url.protocol})`); } if (await isBlockedExtensionUrlWithDns(url.href)) { throw new Error(`Blocked private/internal docs URL: ${url.href}`); } const response = await fetchWithTimeout(url.href, { method: "GET", maxBytes: clampMaxBytes(options.maxBytes), }); const responseBody = response.text ?? (response.json !== undefined ? JSON.stringify(response.json, null, 2) : ""); const content = processWebContent({ url: url.href, body: responseBody, contentType: response.contentType, responseMode: options.responseMode ?? "auto", extract: options.extract ?? "readability", includeLinks: options.includeLinks ?? true, search: options.search, maxChars: options.maxChars, }); const responseForOutput = content.mode === "raw" ? response : compactProviderDocsResponse(response); return { provider: options.provider, catalog, request: { url: url.href }, response: responseForOutput, ...(content.mode === "raw" ? {} : { content }), }; } function compactProviderDocsResponse(response: ProviderApiHttpResponse) { return { status: response.status, statusText: response.statusText, ok: response.ok, elapsedMs: response.elapsedMs, headers: response.headers, contentType: response.contentType, size: response.size, truncated: response.truncated, }; } export async function executeProviderApiRequest( args: ProviderApiRequestArgs, runtime: ProviderApiRuntimeOptions, ) { await assertProviderAllowedAsync(args.provider, runtime); // Check whether this is a built-in or custom provider. const builtIn = isProviderApiId(args.provider) ? getProviderApiConfig(args.provider, runtime.providerOverrides) : null; const customConfig = builtIn ? null : await resolveCustomProvider(args.provider, runtime); if (!builtIn && !customConfig) { const known = await listAllProviderIds(runtime); throw new Error( `Unknown provider "${args.provider}". Known providers: ${known.join(", ")}`, ); } if (customConfig) { return executeCustomProviderApiRequest(args, customConfig, runtime); } // --- built-in provider path (original code) --- const config = builtIn!; if (config.requiresConnectionId && !args.connectionId?.trim()) { throw new Error( `${config.label} Provider API requests require a workspace connectionId.`, ); } const ctx = requireRuntimeCredentialContext( runtime, config.credentialKeys[0] ?? config.id, ); const baseUrl = await resolveBaseUrl(config, runtime, ctx, args); const placeholders = await resolvePlaceholders(config, runtime, ctx, args); const method = normalizeMethod(args.method); const url = buildProviderUrl({ config, baseUrl, rawPath: substituteString(args.path, placeholders), query: substituteUnknown(args.query, placeholders), }); if (await isBlockedExtensionUrlWithDns(url.href)) { throw new Error(`Blocked private/internal provider URL: ${url.href}`); } const auth = args.auth === "none" ? emptyAuth() : await resolveAuth(config, runtime, ctx, args); const extraHeaders = substituteUnknown(args.headers ?? {}, placeholders); const headers = sanitizeOutboundHeaders({ ...(config.defaultHeaders ?? {}), ...(isPlainRecord(extraHeaders) ? extraHeaders : {}), ...auth.headers, }); // Allow a much larger maxBytes ceiling when writing to a workspace file. const effectiveMaxBytes = args.saveToFile ? SAVE_TO_FILE_MAX_BYTES : clampMaxBytes(args.maxBytes); const quotaIdentity = createProviderQuotaIdentity({ appId: runtimeOptionsAppId(runtime), providerId: config.id, ctx, credentialSources: auth.credentialSources, connectionId: args.connectionId, accountId: args.accountId, }); // --- fetchAllPages mode --- if (args.fetchAllPages) { const pageCfg = args.fetchAllPages; const { items, pageCount, lastStatus, lastContentType, truncated, nextCursor, } = await fetchAllPages(pageCfg, async (extra) => { const queryWithCursor = extra?.query ? mergeQueryObjects( substituteUnknown(args.query, placeholders), extra.query, ) : substituteUnknown(args.query, placeholders); const pageUrl = buildProviderUrl({ config, baseUrl, rawPath: substituteString(args.path, placeholders), query: queryWithCursor, }); const bodyWithCursor = extra?.bodyCursor ? setValueAtPath( substituteUnknown(args.body, placeholders), extra.bodyCursor.path, extra.bodyCursor.value, ) : substituteUnknown(args.body, placeholders); const pageBody = prepareBody(bodyWithCursor, headers); const requestKey = createProviderRequestDedupeKey({ method, url: pageUrl.href, body: pageBody, headers, }); const resp = await fetchWithTimeout(pageUrl.href, { method, headers, body: pageBody, maxBytes: effectiveMaxBytes, timeoutMs: clampTimeout(args.timeoutMs), secretValues: auth.secretValues, quota: { identity: quotaIdentity, method, target: describeProviderRequestTarget( pageUrl.href, auth.secretValues, ), requestKey, }, }); return { text: resp.text ?? (resp.json !== undefined ? JSON.stringify(resp.json) : ""), contentType: resp.contentType, status: resp.status, ok: resp.ok, }; }); const allItemsJson = JSON.stringify(items, null, 2); const metadata = { provider: { id: config.id, label: config.label }, pagesRead: pageCount, totalItems: Array.isArray(items) ? items.length : 0, lastStatus, truncated, nextCursor, }; if (args.saveToFile) { const saved = (await handleSaveToFile( args.saveToFile, allItemsJson, lastContentType ?? "application/json", lastStatus, )) as Record; return { ...metadata, ...saved }; } return { ...metadata, items }; } // --- Single request --- const body = prepareBody(substituteUnknown(args.body, placeholders), headers); const requestKey = createProviderRequestDedupeKey({ method, url: url.href, body, headers, }); const response = await fetchWithTimeout(url.href, { method, headers, body, maxBytes: effectiveMaxBytes, timeoutMs: clampTimeout(args.timeoutMs), secretValues: auth.secretValues, quota: { identity: quotaIdentity, method, target: describeProviderRequestTarget(url.href, auth.secretValues), requestKey, }, }); // saveToFile: write full body to workspace file and return compact summary. if (args.saveToFile) { const rawText = response.text ?? (response.json !== undefined ? JSON.stringify(response.json) : ""); const saved = (await handleSaveToFile( args.saveToFile, rawText, response.contentType, response.status, )) as Record; return { provider: { id: config.id, label: config.label }, request: { method, url: redactString(url.href, auth.secretValues), path: redactString(`${url.pathname}${url.search}`, auth.secretValues), }, ...saved, }; } return { provider: { id: config.id, label: config.label, docsUrls: config.docsUrls, specUrls: config.specUrls ?? [], }, request: { method, url: redactString(url.href, auth.secretValues), path: redactString(`${url.pathname}${url.search}`, auth.secretValues), auth: args.auth === "none" ? "none" : describeAuth(config.auth), credentialSources: auth.credentialSources.map((source) => ({ ...source, fingerprint: fingerprint(source.key), })), headerNames: Object.keys(headers).filter( (name) => name.toLowerCase() !== "authorization", ), ...(args.accountId ? { accountId: args.accountId } : {}), ...(args.connectionId ? { connectionId: args.connectionId } : {}), }, response, guidance: [ "This was a raw provider API request. Use provider docs/spec URLs to choose endpoints and include method/path/status plus relevant filters in the methodology. Prefer this escape hatch whenever canned actions are too narrow.", providerAccessErrorGuidance(config, response.status), ] .filter(Boolean) .join(" "), }; } const PROVIDER_ACCESS_ERROR_STATUSES = new Set([401, 403, 404]); function providerAccessErrorGuidance( config: ProviderApiConfig, status: number, ): string | null { if (!PROVIDER_ACCESS_ERROR_STATUSES.has(status)) return null; return config.accessErrorGuidance ?? null; } /** * Resolve a provider OAuth token for a trusted server-side integration bridge * such as a CRM adapter or Google Picker. Callers must keep the result out of * agent, MCP, A2A, logs, persistence, and extension/tool surfaces. */ export async function resolveProviderApiOAuthAccessToken( args: { provider: ProviderApiId; connectionId?: string | null; accountId?: string | null; }, runtime: ProviderApiRuntimeOptions, ): Promise { await assertProviderAllowedAsync(args.provider, runtime); const config = getProviderApiConfig(args.provider, runtime.providerOverrides); const auth = config.auth; const oauthAuth = auth.type === "oauth-bearer" ? auth : auth.type === "oauth-bearer-or-api-key-header" || auth.type === "oauth-bearer-or-bearer-key" ? { type: "oauth-bearer" as const, oauthProvider: auth.oauthProvider, tokenLabel: auth.tokenLabel, workspaceProvider: auth.workspaceProvider, } : auth.type === "oauth-bearer-or-basic" ? { type: "oauth-bearer" as const, oauthProvider: auth.oauthProvider, tokenLabel: auth.tokenLabel, workspaceProvider: auth.workspaceProvider, } : null; if (!oauthAuth) { throw new Error( `Provider API ${args.provider} does not use a direct OAuth bearer token.`, ); } const ctx = requireRuntimeCredentialContext( runtime, config.credentialKeys[0] ?? config.id, ); const credential = oauthAuth.workspaceProvider ? await resolveOptionalConnectionBoundOAuthBearerToken({ auth: oauthAuth, runtime, ctx, workspaceProvider: oauthAuth.workspaceProvider, connectionId: args.connectionId, accountId: args.accountId, }) : await resolveOAuthBearerToken({ auth: oauthAuth, ctx, accountId: args.accountId, }); if (!credential) { throw new Error( `${oauthAuth.tokenLabel} workspace connection is not available to ${runtime.appId}.`, ); } return { accessToken: credential.value, accountId: credential.accountId ?? null, accountLabel: credential.accountLabel ?? null, connectionId: credential.connectionId ?? null, connectionLabel: credential.connectionLabel ?? null, }; } const GITHUB_REPO_FILES_DEFAULT_MAX = 1_000; const GITHUB_REPO_FILES_MAX = 10_000; const GITHUB_CODE_SEARCH_DEFAULT_PER_PAGE = 30; const GITHUB_CODE_SEARCH_MAX_PER_PAGE = 100; export async function listGitHubRepositoryFiles( args: GitHubRepositoryFileListArgs, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const pathPrefix = normalizeGitHubFilePath(args.path); const maxFiles = clampPositiveInt( args.maxFiles, GITHUB_REPO_FILES_DEFAULT_MAX, GITHUB_REPO_FILES_MAX, ); if (args.recursive) { const ref = args.ref?.trim() || (await resolveGitHubDefaultBranch(args, runtime)); const { json, response } = await executeGitHubJsonRequest({ runtime, label: "list repository tree", args: { provider: "github", path: `${githubRepoApiPath(repository)}/git/trees/${encodeURIComponent( ref, )}`, query: { recursive: "1" }, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const tree = asRecord(json); const rawEntries = Array.isArray(tree.tree) ? tree.tree : []; const entries = rawEntries .map((entry) => normalizeGitHubTreeEntry(entry, { owner: repository.owner, repo: repository.repo, ref, }), ) .filter((entry): entry is GitHubRepositoryFileEntry => !!entry) .filter((entry) => args.includeDirectories || entry.type === "file") .filter((entry) => githubPathMatchesPrefix(entry.path, pathPrefix)); const limitedEntries = entries.slice(0, maxFiles); return { repository, ref, path: pathPrefix, recursive: true, entries: limitedEntries, totalCount: entries.length, truncated: Boolean(tree.truncated) || entries.length > limitedEntries.length, providerTruncated: Boolean(tree.truncated), response: compactResponseStatus(response), }; } const { json, response } = await executeGitHubJsonRequest({ runtime, label: "list repository contents", args: { provider: "github", path: githubContentsApiPath(repository, pathPrefix), query: args.ref ? { ref: args.ref } : undefined, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const rawEntries = Array.isArray(json) ? json : [json]; const entries = rawEntries .map(normalizeGitHubContentsEntry) .filter((entry): entry is GitHubRepositoryFileEntry => !!entry) .filter((entry) => args.includeDirectories || entry.type === "file"); const limitedEntries = entries.slice(0, maxFiles); return { repository, ref: args.ref?.trim() || null, path: pathPrefix, recursive: false, entries: limitedEntries, totalCount: entries.length, truncated: entries.length > limitedEntries.length, providerTruncated: false, response: compactResponseStatus(response), }; } export async function searchGitHubRepositoryFiles( args: GitHubRepositoryFileSearchArgs, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const query = args.query?.trim() ?? ""; if (!query) { const listed = await listGitHubRepositoryFiles( { owner: repository.owner, repo: repository.repo, path: args.path, ref: args.ref, recursive: true, includeDirectories: false, maxFiles: GITHUB_REPO_FILES_MAX, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, runtime, ); const filtered = listed.entries.filter((entry) => githubTreeEntryMatchesSearchFilters(entry, args), ); const maxFiles = clampPositiveInt( args.maxFiles, GITHUB_REPO_FILES_DEFAULT_MAX, GITHUB_REPO_FILES_MAX, ); const limitedItems = filtered.slice(0, maxFiles); return { repository, mode: "tree-filter", query: null, ref: listed.ref, items: limitedItems, totalCount: filtered.length, truncated: listed.truncated || filtered.length > limitedItems.length, incompleteResults: listed.providerTruncated, response: listed.response, }; } const perPage = clampPositiveInt( args.perPage, GITHUB_CODE_SEARCH_DEFAULT_PER_PAGE, GITHUB_CODE_SEARCH_MAX_PER_PAGE, ); const page = clampPositiveInt(args.page, 1, 100); const q = buildGitHubCodeSearchQuery(repository, args, query); const { json, response } = await executeGitHubJsonRequest({ runtime, label: "search repository code", args: { provider: "github", path: "/search/code", query: { q, per_page: perPage, page }, headers: args.includeTextMatches ? { Accept: "application/vnd.github.text-match+json" } : undefined, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const body = asRecord(json); const rawItems = Array.isArray(body.items) ? body.items : []; const items = rawItems .map(normalizeGitHubCodeSearchEntry) .filter((entry): entry is GitHubRepositoryFileEntry => !!entry); return { repository, mode: "code-search", query: q, ref: null, items, totalCount: typeof body.total_count === "number" && Number.isFinite(body.total_count) ? body.total_count : items.length, truncated: items.length >= perPage, incompleteResults: Boolean(body.incomplete_results), response: compactResponseStatus(response), }; } export async function readGitHubRepositoryFile( args: GitHubRepositoryFileReadArgs, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const path = requireGitHubFilePath(args.path); const { json, response } = await executeGitHubJsonRequest({ runtime, label: "read repository file", args: { provider: "github", path: githubContentsApiPath(repository, path), query: args.ref ? { ref: args.ref } : undefined, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); if (Array.isArray(json)) { throw new Error( `GitHub path "${path}" is a directory. Use listGitHubRepositoryFiles or the github-repo-files action with operation="list".`, ); } const file = asRecord(json); const encoding = typeof file.encoding === "string" ? file.encoding.toLowerCase() : null; const rawBase64 = encoding === "base64" && typeof file.content === "string" ? file.content.replace(/\s+/g, "") : null; const content = rawBase64 ? Buffer.from(rawBase64, "base64").toString("utf8") : null; const sha = typeof file.sha === "string" ? file.sha : ""; if (!sha) throw new Error(`GitHub read for "${path}" did not return a SHA.`); return { repository, path: typeof file.path === "string" ? file.path : path, ref: args.ref?.trim() || null, name: typeof file.name === "string" ? file.name : githubBasename(path), type: typeof file.type === "string" ? file.type : "file", sha, size: typeof file.size === "number" ? file.size : 0, encoding, content, contentBase64: rawBase64, url: typeof file.url === "string" ? file.url : null, htmlUrl: typeof file.html_url === "string" ? file.html_url : null, downloadUrl: typeof file.download_url === "string" ? file.download_url : null, response: compactResponseStatus(response), }; } export async function writeGitHubRepositoryFile( args: GitHubRepositoryFileWriteArgs, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const path = requireGitHubFilePath(args.path); const message = args.message.trim(); if (!message) throw new Error("GitHub file write requires a commit message."); let sha = args.sha?.trim(); if (!sha && args.overwriteExisting) { try { const existing = await readGitHubRepositoryFile( { owner: repository.owner, repo: repository.repo, path, ref: args.branch, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, runtime, ); sha = existing.sha; } catch (error) { if (!isGitHubNotFoundError(error)) throw error; } } const body: Record = { message, content: Buffer.from(args.content, "utf8").toString("base64"), }; if (sha) body.sha = sha; if (args.branch?.trim()) body.branch = args.branch.trim(); if (args.committer) body.committer = args.committer; if (args.author) body.author = args.author; const { json, response } = await executeGitHubJsonRequest({ runtime, label: "write repository file", args: { provider: "github", method: "PUT", path: githubContentsApiPath(repository, path), body, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const result = asRecord(json); const content = asRecord(result.content); const commit = asRecord(result.commit); return { repository, path, branch: args.branch?.trim() || null, content: { name: stringOrNull(content.name), path: stringOrNull(content.path), sha: stringOrNull(content.sha), url: stringOrNull(content.url), htmlUrl: stringOrNull(content.html_url), }, commit: { sha: stringOrNull(commit.sha), url: stringOrNull(commit.url), htmlUrl: stringOrNull(commit.html_url), message: stringOrNull(commit.message), }, response: compactResponseStatus(response), }; } export async function deleteGitHubRepositoryFile( args: GitHubRepositoryFileDeleteArgs, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const path = requireGitHubFilePath(args.path); const message = args.message.trim(); if (!message) { throw new Error("GitHub file delete requires a commit message."); } let sha = args.sha?.trim(); if (!sha) { const existing = await readGitHubRepositoryFile( { owner: repository.owner, repo: repository.repo, path, ref: args.branch, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, runtime, ); sha = existing.sha; } const body: Record = { message, sha }; if (args.branch?.trim()) body.branch = args.branch.trim(); if (args.committer) body.committer = args.committer; if (args.author) body.author = args.author; const { json, response } = await executeGitHubJsonRequest({ runtime, label: "delete repository file", args: { provider: "github", method: "DELETE", path: githubContentsApiPath(repository, path), body, connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const result = asRecord(json); const commit = asRecord(result.commit); return { repository, path, branch: args.branch?.trim() || null, commit: { sha: stringOrNull(commit.sha), url: stringOrNull(commit.url), htmlUrl: stringOrNull(commit.html_url), message: stringOrNull(commit.message), }, response: compactResponseStatus(response), }; } async function executeGitHubJsonRequest(options: { runtime: ProviderApiRuntimeOptions; label: string; args: Omit & { provider?: "github" }; }): Promise<{ json: unknown; response: ProviderApiHttpResponse }> { const result = (await executeProviderApiRequest( { provider: "github", ...options.args }, options.runtime, )) as Record; const response = result.response as ProviderApiHttpResponse | undefined; if (!response) { throw new Error(`GitHub ${options.label} returned an unexpected result.`); } if (!response.ok) { throw new Error( `GitHub ${options.label} failed with HTTP ${response.status}${ response.statusText ? ` ${response.statusText}` : "" }${providerResponseErrorDetail(response)}`, ); } return { json: response.json, response }; } async function resolveGitHubDefaultBranch( args: GitHubRepositoryRef & { connectionId?: string | null; timeoutMs?: number; maxBytes?: number; }, runtime: ProviderApiRuntimeOptions, ): Promise { const repository = normalizeGitHubRepository(args); const { json } = await executeGitHubJsonRequest({ runtime, label: "get repository metadata", args: { provider: "github", path: githubRepoApiPath(repository), connectionId: args.connectionId, timeoutMs: args.timeoutMs, maxBytes: args.maxBytes, }, }); const body = asRecord(json); const defaultBranch = stringOrNull(body.default_branch); if (!defaultBranch) { throw new Error( `GitHub repository ${repository.owner}/${repository.repo} did not return a default branch.`, ); } return defaultBranch; } function normalizeGitHubRepository( args: GitHubRepositoryRef, ): GitHubRepositoryRef { const owner = args.owner.trim(); const repo = args.repo.trim().replace(/\.git$/i, ""); if (!owner) throw new Error("GitHub repository owner is required."); if (!repo) throw new Error("GitHub repository name is required."); return { owner, repo }; } function githubRepoApiPath(repository: GitHubRepositoryRef): string { return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent( repository.repo, )}`; } function githubContentsApiPath( repository: GitHubRepositoryRef, filePath: string, ): string { const encodedPath = encodeGitHubFilePath(filePath); return `${githubRepoApiPath(repository)}/contents${ encodedPath ? `/${encodedPath}` : "" }`; } function encodeGitHubFilePath(filePath: string): string { return normalizeGitHubFilePath(filePath) .split("/") .filter(Boolean) .map((part) => encodeURIComponent(part)) .join("/"); } function normalizeGitHubFilePath(filePath: string | undefined): string { return (filePath ?? "").trim().replace(/^\/+|\/+$/g, ""); } function requireGitHubFilePath(filePath: string): string { const normalized = normalizeGitHubFilePath(filePath); if (!normalized) throw new Error("GitHub file path is required."); return normalized; } function githubBasename(filePath: string): string { return filePath.split("/").filter(Boolean).pop() ?? filePath; } function githubPathMatchesPrefix(path: string, prefix: string): boolean { if (!prefix) return true; return path === prefix || path.startsWith(`${prefix}/`); } function normalizeGitHubTreeEntry( value: unknown, options: GitHubRepositoryRef & { ref: string }, ): GitHubRepositoryFileEntry | null { const entry = asRecord(value); const path = stringOrNull(entry.path); if (!path) return null; const type = stringOrNull(entry.type); const normalizedType = type === "blob" ? "file" : type === "tree" ? "dir" : type === "commit" ? "submodule" : "unknown"; return { name: githubBasename(path), path, type: normalizedType, sha: stringOrNull(entry.sha), size: numberOrNull(entry.size), url: stringOrNull(entry.url), htmlUrl: normalizedType === "file" ? githubWebFileUrl(options, path) : normalizedType === "dir" ? githubWebTreeUrl(options, path) : null, downloadUrl: null, }; } function normalizeGitHubContentsEntry( value: unknown, ): GitHubRepositoryFileEntry | null { const entry = asRecord(value); const path = stringOrNull(entry.path); if (!path) return null; const type = stringOrNull(entry.type); return { name: stringOrNull(entry.name) ?? githubBasename(path), path, type: type === "file" || type === "dir" || type === "symlink" || type === "submodule" ? type : "unknown", sha: stringOrNull(entry.sha), size: numberOrNull(entry.size), url: stringOrNull(entry.url), htmlUrl: stringOrNull(entry.html_url), downloadUrl: stringOrNull(entry.download_url), }; } function normalizeGitHubCodeSearchEntry( value: unknown, ): GitHubRepositoryFileEntry | null { return normalizeGitHubContentsEntry({ ...asRecord(value), type: "file" }); } function githubTreeEntryMatchesSearchFilters( entry: GitHubRepositoryFileEntry, args: GitHubRepositoryFileSearchArgs, ): boolean { const filename = args.filename?.trim(); if (filename && entry.name !== filename) return false; const extension = args.extension?.trim().replace(/^\./, ""); if (extension && !entry.path.endsWith(`.${extension}`)) return false; const pathFilter = normalizeGitHubFilePath(args.path); if (pathFilter && !entry.path.includes(pathFilter)) return false; return true; } function buildGitHubCodeSearchQuery( repository: GitHubRepositoryRef, args: GitHubRepositoryFileSearchArgs, query: string, ): string { const qualifiers = [`repo:${repository.owner}/${repository.repo}`]; const path = normalizeGitHubFilePath(args.path); if (path) qualifiers.push(`path:${quoteGitHubSearchQualifier(path)}`); if (args.filename?.trim()) { qualifiers.push( `filename:${quoteGitHubSearchQualifier(args.filename.trim())}`, ); } if (args.extension?.trim()) { qualifiers.push( `extension:${quoteGitHubSearchQualifier( args.extension.trim().replace(/^\./, ""), )}`, ); } if (args.language?.trim()) { qualifiers.push( `language:${quoteGitHubSearchQualifier(args.language.trim())}`, ); } return [query, "in:file", ...qualifiers].join(" "); } function quoteGitHubSearchQualifier(value: string): string { return /\s/.test(value) ? JSON.stringify(value) : value; } function githubWebFileUrl( options: GitHubRepositoryRef & { ref: string }, filePath: string, ): string { return `https://github.com/${encodeURIComponent( options.owner, )}/${encodeURIComponent(options.repo)}/blob/${encodeURIComponent( options.ref, )}/${encodeGitHubFilePath(filePath)}`; } function githubWebTreeUrl( options: GitHubRepositoryRef & { ref: string }, filePath: string, ): string { return `https://github.com/${encodeURIComponent( options.owner, )}/${encodeURIComponent(options.repo)}/tree/${encodeURIComponent( options.ref, )}/${encodeGitHubFilePath(filePath)}`; } function clampPositiveInt( value: number | undefined, defaultValue: number, maxValue: number, ): number { if (!Number.isFinite(value) || value! <= 0) return defaultValue; return Math.min(maxValue, Math.floor(value!)); } function compactResponseStatus( response: ProviderApiHttpResponse, ): Pick { return { status: response.status, statusText: response.statusText, ok: response.ok, }; } function providerResponseErrorDetail( response: ProviderApiHttpResponse, ): string { const body = asRecord(response.json); const message = stringOrNull(body.message); const detail = message ?? (typeof response.text === "string" ? response.text.replace(/\s+/g, " ").trim().slice(0, 500) : ""); return detail ? `: ${detail}` : ""; } function isGitHubNotFoundError(error: unknown): boolean { return error instanceof Error && /\bHTTP 404\b/.test(error.message); } function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function stringOrNull(value: unknown): string | null { return typeof value === "string" ? value : null; } function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } // --------------------------------------------------------------------------- // Custom provider execution // --------------------------------------------------------------------------- async function executeCustomProviderApiRequest( args: ProviderApiRequestArgs, customConfig: CustomProviderConfig, runtime: ProviderApiRuntimeOptions, ): Promise { const ctx = requireRuntimeCredentialContext(runtime, customConfig.id); const method = normalizeMethod(args.method); const baseUrl = customConfig.baseUrl; // Build a lightweight ProviderApiConfig-like object so we can reuse // buildProviderUrl (which validates allowed hosts). const syntheticConfig: ProviderApiConfig = { id: customConfig.id as ProviderApiId, label: customConfig.label, defaultBaseUrl: baseUrl, auth: { type: "none" }, credentialKeys: [], docsUrls: customConfig.docsUrls, allowedHostSuffixes: customConfig.allowedHostSuffixes, defaultHeaders: customConfig.defaultHeaders, }; const url = buildProviderUrl({ config: syntheticConfig, baseUrl, rawPath: args.path, query: args.query, }); if (await isBlockedExtensionUrlWithDns(url.href)) { throw new Error(`Blocked private/internal provider URL: ${url.href}`); } const auth = args.auth === "none" ? emptyAuth() : await resolveCustomAuth(customConfig, runtime, ctx, args); const extraHeaders = args.headers ?? {}; const headers = sanitizeOutboundHeaders({ ...(customConfig.defaultHeaders ?? {}), ...(isPlainRecord(extraHeaders) ? extraHeaders : {}), ...auth.headers, }); const body = prepareBody(args.body, headers); const effectiveMaxBytes = args.saveToFile ? SAVE_TO_FILE_MAX_BYTES : clampMaxBytes(args.maxBytes); const quotaIdentity = createProviderQuotaIdentity({ appId: runtimeOptionsAppId(runtime), providerId: customConfig.id, ctx, credentialSources: auth.credentialSources, connectionId: args.connectionId, accountId: args.accountId, }); // --- fetchAllPages mode (same cursor pagination as built-in providers) --- if (args.fetchAllPages) { const pageCfg = args.fetchAllPages; const { items, pageCount, lastStatus, lastContentType, truncated, nextCursor, } = await fetchAllPages(pageCfg, async (extra) => { const queryWithCursor = extra?.query ? mergeQueryObjects(args.query, extra.query) : args.query; const pageUrl = buildProviderUrl({ config: syntheticConfig, baseUrl, rawPath: args.path, query: queryWithCursor, }); const bodyWithCursor = extra?.bodyCursor ? setValueAtPath( args.body, extra.bodyCursor.path, extra.bodyCursor.value, ) : args.body; const pageBody = prepareBody(bodyWithCursor, headers); const requestKey = createProviderRequestDedupeKey({ method, url: pageUrl.href, body: pageBody, headers, }); const resp = await fetchWithTimeout(pageUrl.href, { method, headers, body: pageBody, maxBytes: effectiveMaxBytes, timeoutMs: clampTimeout(args.timeoutMs), secretValues: auth.secretValues, quota: { identity: quotaIdentity, method, target: describeProviderRequestTarget( pageUrl.href, auth.secretValues, ), requestKey, }, }); return { text: resp.text ?? (resp.json !== undefined ? JSON.stringify(resp.json) : ""), contentType: resp.contentType, status: resp.status, ok: resp.ok, }; }); const allItemsJson = JSON.stringify(items, null, 2); const metadata = { provider: { id: customConfig.id, label: customConfig.label, custom: true, }, pagesRead: pageCount, totalItems: Array.isArray(items) ? items.length : 0, lastStatus, truncated, nextCursor, }; if (args.saveToFile) { const saved = (await handleSaveToFile( args.saveToFile, allItemsJson, lastContentType ?? "application/json", lastStatus, )) as Record; return { ...metadata, ...saved }; } return { ...metadata, items }; } const response = await fetchWithTimeout(url.href, { method, headers, body, maxBytes: effectiveMaxBytes, timeoutMs: clampTimeout(args.timeoutMs), secretValues: auth.secretValues, quota: { identity: quotaIdentity, method, target: describeProviderRequestTarget(url.href, auth.secretValues), requestKey: createProviderRequestDedupeKey({ method, url: url.href, body, headers, }), }, }); if (args.saveToFile) { const rawText = response.text ?? (response.json !== undefined ? JSON.stringify(response.json) : ""); const saved = (await handleSaveToFile( args.saveToFile, rawText, response.contentType, response.status, )) as Record; return { provider: { id: customConfig.id, label: customConfig.label, custom: true, }, request: { method, url: redactString(url.href, auth.secretValues), path: redactString(`${url.pathname}${url.search}`, auth.secretValues), }, ...saved, }; } return { provider: { id: customConfig.id, label: customConfig.label, docsUrls: customConfig.docsUrls, specUrls: [], custom: true, }, request: { method, url: redactString(url.href, auth.secretValues), path: redactString(`${url.pathname}${url.search}`, auth.secretValues), auth: args.auth === "none" ? "none" : describeCustomAuth(customConfig.auth), credentialSources: auth.credentialSources.map((source) => ({ ...source, fingerprint: fingerprint(source.key), })), headerNames: Object.keys(headers).filter( (name) => name.toLowerCase() !== "authorization", ), }, response, guidance: "This was a raw provider API request to a custom provider. Use provider docs URLs to choose endpoints.", }; } async function resolveCustomAuth( customConfig: CustomProviderConfig, runtime: ProviderApiRuntimeOptions, ctx: CredentialContext, args: ProviderApiRequestArgs, ): Promise { const auth = customConfig.auth; if (auth.type === "none") return emptyAuth(); if (auth.type === "bearer") { const credential = await resolveRequiredCredentialByKey({ provider: customConfig.id, key: auth.credentialKey, ctx, runtime, connectionId: args.connectionId, }); return { headers: { Authorization: `Bearer ${credential.value}` }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "basic") { const username = await resolveRequiredCredentialByKey({ provider: customConfig.id, key: auth.usernameKey, ctx, runtime, connectionId: args.connectionId, }); const password = auth.passwordKey === auth.usernameKey ? username : await resolveRequiredCredentialByKey({ provider: customConfig.id, key: auth.passwordKey, ctx, runtime, connectionId: args.connectionId, }); const encoded = Buffer.from(`${username.value}:${password.value}`).toString( "base64", ); return { headers: { Authorization: `Basic ${encoded}` }, credentialSources: [ omitCredentialValue(username), ...(password.key === username.key ? [] : [omitCredentialValue(password)]), ], secretValues: [username.value, password.value, encoded], }; } if (auth.type === "api-key-header") { const credential = await resolveRequiredCredentialByKey({ provider: customConfig.id, key: auth.credentialKey, ctx, runtime, connectionId: args.connectionId, }); return { headers: { [auth.headerName]: credential.value }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } return emptyAuth(); } /** Resolve a credential by key name (no workspace-provider lookup for custom). */ async function resolveRequiredCredentialByKey(options: { provider: string; key: string; ctx: CredentialContext; runtime: ProviderApiRuntimeOptions; connectionId?: string | null; }): Promise { const localCredentialSource = options.runtime.localCredentialSource ?? "app_local"; const lookup: ProviderApiCredentialLookupOptions = { appId: options.runtime.appId, provider: options.provider, key: options.key, ctx: options.ctx, workspaceProvider: undefined, connectionId: options.connectionId, localCredentialSource, }; const resolver = options.runtime.resolveCredential ?? defaultProviderApiCredentialResolver; const credential = await resolver(lookup); if (!credential?.value) { throw new Error( `Credential "${options.key}" not configured for custom provider "${options.provider}".`, ); } return credential; } function describeCustomAuth(auth: CustomProviderAuthKind): string { if (auth.type === "none") return "none"; if (auth.type === "bearer") return "bearer"; if (auth.type === "basic") return "basic"; if (auth.type === "api-key-header") return `api-key-header:${auth.headerName}`; return "unknown"; } // --------------------------------------------------------------------------- // Catalog helpers with custom provider support // --------------------------------------------------------------------------- /** * Convert a custom provider to the same catalog shape as built-in providers. */ function customProviderToCatalogEntry(config: CustomProviderConfig) { return { id: config.id, label: config.label, defaultBaseUrl: config.baseUrl, baseUrlCredentialKey: null, auth: describeCustomAuth(config.auth), credentialKeys: extractCredentialKeysFromCustomAuth(config.auth), docsUrls: config.docsUrls, specUrls: [] as string[], allowedHostSuffixes: config.allowedHostSuffixes, placeholders: [] as unknown[], defaultHeaders: config.defaultHeaders, examples: [] as unknown[], notes: config.notes ? [config.notes] : ([] as string[]), corpusRecipes: [] as unknown[], templateUses: [] as string[], custom: true, }; } function extractCredentialKeysFromCustomAuth( auth: CustomProviderAuthKind, ): string[] { if (auth.type === "bearer") return [auth.credentialKey]; if (auth.type === "basic") { return auth.usernameKey === auth.passwordKey ? [auth.usernameKey] : [auth.usernameKey, auth.passwordKey]; } if (auth.type === "api-key-header") return [auth.credentialKey]; return []; } /** * List catalog entries including custom providers (merged after built-ins). */ async function listProviderApiCatalogWithCustom( provider: ProviderApiId | string | undefined, options: { providerIds?: readonly (ProviderApiId | string)[]; providerOverrides?: readonly ProviderApiConfig[]; }, runtime: ProviderApiRuntimeOptions, ): Promise { const customConfigs = runtime.getCustomProviders ? await runtime.getCustomProviders() : []; if (provider) { // Check built-ins first if (isProviderApiId(provider)) { return listProviderApiCatalog(provider, options) as unknown[]; } // Check custom const custom = customConfigs.find((c) => c.id === provider); if (custom) return [customProviderToCatalogEntry(custom)]; const known = [ ...normalizeProviderIds(options.providerIds), ...customConfigs.map((c) => c.id), ]; throw new Error( `Unknown provider "${provider}". Known providers: ${known.join(", ")}`, ); } const builtInEntries = listProviderApiCatalog( undefined, options, ) as unknown[]; const builtInIds = new Set( (options.providerIds ?? PROVIDER_API_IDS).map(String), ); const customEntries = customConfigs .filter((c) => !builtInIds.has(c.id)) .map(customProviderToCatalogEntry); return [...builtInEntries, ...customEntries]; } /** * Look up a custom provider by id from the runtime loader. */ async function resolveCustomProvider( id: string, runtime: ProviderApiRuntimeOptions, ): Promise { if (!runtime.getCustomProviders) return null; const configs = await runtime.getCustomProviders(); return configs.find((c) => c.id === id) ?? null; } /** * List all provider ids (built-in + custom) visible to this runtime. */ async function listAllProviderIds( runtime: ProviderApiRuntimeOptions, ): Promise { const builtIn = normalizeProviderIds(runtime.providerIds).map(String); if (!runtime.getCustomProviders) return builtIn; const custom = await runtime.getCustomProviders(); return [...builtIn, ...custom.map((c) => c.id)]; } /** * Assert that a provider is either a known built-in or a registered custom * provider. Throws with a descriptive message listing known providers. */ async function assertProviderAllowedAsync( provider: string, runtime: ProviderApiRuntimeOptions, ): Promise { // Built-in check (fast path) if (isProviderApiId(provider)) { // Still check the providerIds whitelist if set const allowed = normalizeProviderIds(runtime.providerIds); if (!allowed.includes(provider as ProviderApiId)) { throw new Error(`Provider API ${provider} is not enabled for this app.`); } return; } // Custom provider check const custom = await resolveCustomProvider(provider, runtime); if (custom) return; const known = await listAllProviderIds(runtime); throw new Error( `Unknown provider "${provider}". Known providers: ${known.join(", ")}`, ); } export async function defaultProviderApiCredentialResolver( options: ProviderApiCredentialLookupOptions, ): Promise { if (options.workspaceProvider) { const result = await resolveWorkspaceConnectionCredentialForApp({ appId: options.appId, provider: options.workspaceProvider, key: options.key, connectionId: options.connectionId, userEmail: options.ctx.userEmail, orgId: options.ctx.orgId, }); if (result.available && result.value) { return { key: result.provenance?.resolvedKey ?? result.key, value: result.value, source: "workspace_connection", provider: result.provider, connectionId: result.provenance?.connectionId, connectionLabel: result.provenance?.connectionLabel, scope: typeof result.provenance?.secretScope === "string" ? result.provenance.secretScope : undefined, }; } } const value = await resolveCredential(options.key, options.ctx); if (!value) return null; return { key: options.key, value, source: options.localCredentialSource, provider: options.provider, }; } function normalizeProviderIds( providerIds?: readonly (ProviderApiId | string)[], ): ProviderApiId[] { if (!providerIds) return [...PROVIDER_API_IDS]; const result: ProviderApiId[] = []; const seen = new Set(); for (const providerId of providerIds) { if (!isProviderApiId(providerId)) { throw new Error(`Unsupported provider API: ${providerId}`); } if (seen.has(providerId)) continue; seen.add(providerId); result.push(providerId); } return result; } function assertProviderAllowed( provider: ProviderApiId | string, providerIds?: readonly (ProviderApiId | string)[], ) { const allowed = normalizeProviderIds(providerIds); if (!allowed.includes(provider as ProviderApiId)) { throw new Error(`Provider API ${provider} is not enabled for this app.`); } } function describeAuth(auth: ProviderApiAuthKind): string { if (auth.type === "none") return "none"; if (auth.type === "bearer") return "bearer"; if (auth.type === "basic") return "basic"; if (auth.type === "basic-raw") return "basic"; if (auth.type === "api-key-header") return `api-key-header:${auth.header}`; if (auth.type === "google-service-account") return "google-service-account"; if (auth.type === "oauth-bearer") return `oauth-bearer:${auth.oauthProvider}`; if (auth.type === "oauth-bearer-or-api-key-header") { return `oauth-bearer:${auth.oauthProvider}-or-api-key-header:${auth.header}:${(auth.fallbackKeys ?? [auth.key]).join(",")}`; } if (auth.type === "oauth-bearer-or-bearer-key") { return `oauth-bearer:${auth.oauthProvider}-or-bearer-key:${(auth.fallbackKeys ?? [auth.key]).join(",")}`; } if (auth.type === "oauth-bearer-or-basic") { return `oauth-bearer:${auth.oauthProvider}-or-basic:${auth.usernameKey}:${auth.passwordKey}`; } return "prometheus-basic-or-bearer"; } function requireRuntimeCredentialContext( runtime: ProviderApiRuntimeOptions, credentialKey: string, ): CredentialContext { const ctx = runtime.getCredentialContext?.() ?? getCredentialContext(); if (!ctx) { throw new Error( `Cannot resolve credential "${credentialKey}" outside an authenticated request context.`, ); } return ctx; } async function resolveBaseUrl( config: ProviderApiConfig, runtime: ProviderApiRuntimeOptions, ctx: CredentialContext, args: ProviderApiRequestArgs, ): Promise { const oauthBaseUrl = await resolveWorkspaceOAuthBaseUrl( config, runtime, args, ); if (oauthBaseUrl) return oauthBaseUrl; if (!config.baseUrlCredentialKey) return config.defaultBaseUrl; const auth = config.auth; const workspaceProvider = auth.type === "oauth-bearer" || auth.type === "oauth-bearer-or-api-key-header" || auth.type === "oauth-bearer-or-bearer-key" || auth.type === "oauth-bearer-or-basic" ? auth.workspaceProvider : undefined; const configured = await resolveCredentialValue({ config, runtime, ctx, key: config.baseUrlCredentialKey, args, workspaceProvider, }); return (configured || config.defaultBaseUrl).replace(/\/+$/, ""); } async function resolveWorkspaceOAuthBaseUrl( config: ProviderApiConfig, runtime: ProviderApiRuntimeOptions, args: ProviderApiRequestArgs, ): Promise { const auth = config.auth; const workspaceProvider = auth.type === "oauth-bearer" || auth.type === "oauth-bearer-or-api-key-header" || auth.type === "oauth-bearer-or-bearer-key" || auth.type === "oauth-bearer-or-basic" ? auth.workspaceProvider : undefined; if (workspaceProvider !== "jira" && workspaceProvider !== "salesforce") { return null; } let resolved: Awaited>; try { resolved = await resolveWorkspaceConnectionForApp({ appId: runtime.appId, provider: workspaceProvider, connectionId: args.connectionId ?? undefined, requireConnected: true, }); } catch (error) { if ( error instanceof Error && error.message === "Workspace connections require an authenticated user." ) { return null; } throw error; } if (!resolved.available || !resolved.connection) return null; const connectionConfig = resolved.connection.config; if (connectionConfig.credentialMode !== "oauth") return null; const baseUrl = workspaceProvider === "salesforce" ? connectionConfig.salesforceInstanceUrl : connectionConfig.atlassianApiBaseUrl; if (typeof baseUrl !== "string" || !baseUrl.trim()) return null; if (workspaceProvider === "salesforce" && !isSalesforceInstanceUrl(baseUrl)) { return null; } return baseUrl.replace(/\/+$/, ""); } function isSalesforceInstanceUrl(value: string): boolean { try { const url = new URL(value); const host = url.hostname.toLowerCase(); return ( url.protocol === "https:" && !url.username && !url.password && !url.port && (host === "salesforce.com" || host.endsWith(".salesforce.com")) ); } catch { return false; } } async function resolvePlaceholders( config: ProviderApiConfig, runtime: ProviderApiRuntimeOptions, ctx: CredentialContext, args: ProviderApiRequestArgs, ): Promise> { const placeholders: Record = {}; for (const placeholder of config.placeholders ?? []) { const value = await resolveCredentialValue({ config, runtime, ctx, key: placeholder.credentialKey, args, }); if (value) placeholders[placeholder.name] = value; } return placeholders; } async function resolveCredentialValue(options: { config: ProviderApiConfig; runtime: ProviderApiRuntimeOptions; ctx: CredentialContext; key: string; args: ProviderApiRequestArgs; workspaceProvider?: string; }): Promise { const credential = await resolveOptionalCredential({ provider: options.config.id, workspaceProvider: options.workspaceProvider, key: options.key, ctx: options.ctx, runtime: options.runtime, connectionId: options.args.connectionId, }); return credential?.value; } function substituteString( value: string, placeholders: Record, ): string { let result = value; for (const [name, replacement] of Object.entries(placeholders)) { result = result.split(`{${name}}`).join(replacement); } return result; } function substituteUnknown( value: unknown, placeholders: Record, ): unknown { if (typeof value === "string") return substituteString(value, placeholders); if (Array.isArray(value)) { return value.map((item) => substituteUnknown(item, placeholders)); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, entry]) => [ key, substituteUnknown(entry, placeholders), ]), ); } return value; } function isPlainRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } function buildProviderUrl(options: { config: ProviderApiConfig; baseUrl: string; rawPath: string; query: unknown; }): URL { const base = new URL(options.baseUrl); const rawPath = options.rawPath.trim(); const url = /^https?:\/\//i.test(rawPath) ? new URL(rawPath) : new URL(joinProviderUrlPath(base, rawPath), base.origin); if (!isAllowedProviderUrl(url, base, options.config)) { throw new Error( `${options.config.label} API requests must stay on the configured provider host or registered provider host suffix.`, ); } for (const [key, value] of queryEntries(options.query)) { url.searchParams.append(key, value); } return url; } function joinProviderUrlPath(base: URL, rawPath: string): string { const basePath = base.pathname.replace(/\/+$/, ""); const providerPath = rawPath.replace(/^\/+/, ""); if (!providerPath) return basePath || "/"; const baseSegments = basePath.split("/").filter(Boolean); const providerSegments = providerPath.split("/").filter(Boolean); if ( baseSegments.length > 0 && providerSegments.length >= baseSegments.length && baseSegments.every((segment, index) => segment === providerSegments[index]) ) { return `/${providerPath}`; } return `${basePath}/${providerPath}`; } function isAllowedProviderUrl( url: URL, base: URL, config: ProviderApiConfig, ): boolean { if (url.protocol !== "https:" && url.protocol !== "http:") return false; if (url.origin === base.origin) return true; const host = url.hostname.toLowerCase(); return (config.allowedHostSuffixes ?? []).some((suffix) => { const normalized = suffix.toLowerCase().replace(/^\./, ""); return host === normalized || host.endsWith(`.${normalized}`); }); } function queryEntries(value: unknown): Array<[string, string]> { if (!value) return []; if (typeof value === "string") { const params = new URLSearchParams(value.replace(/^\?/, "")); return Array.from(params.entries()); } if (typeof value !== "object" || Array.isArray(value)) return []; const entries: Array<[string, string]> = []; for (const [key, raw] of Object.entries(value)) { if (raw === undefined || raw === null) continue; if (Array.isArray(raw)) { for (const item of raw) entries.push([key, String(item)]); } else { entries.push([key, String(raw)]); } } return entries; } async function resolveAuth( config: ProviderApiConfig, runtime: ProviderApiRuntimeOptions, ctx: CredentialContext, args: ProviderApiRequestArgs, ): Promise { const auth = config.auth; if (auth.type === "none") return emptyAuth(); if (auth.type === "bearer") { const credential = await resolveAnyCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, keys: auth.keys, ctx, runtime, connectionId: args.connectionId, }); return { headers: { Authorization: `Bearer ${credential.value}` }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "basic") { const username = await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.usernameKey, ctx, runtime, connectionId: args.connectionId, }); const password = auth.passwordKey === auth.usernameKey ? username : await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.passwordKey, ctx, runtime, connectionId: args.connectionId, }); const encoded = Buffer.from(`${username.value}:${password.value}`).toString( "base64", ); return { headers: { Authorization: `Basic ${encoded}` }, credentialSources: [ omitCredentialValue(username), ...(password.key === username.key ? [] : [omitCredentialValue(password)]), ], secretValues: [username.value, password.value, encoded], }; } if (auth.type === "basic-raw") { const credential = await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.key, ctx, runtime, connectionId: args.connectionId, }); const encoded = Buffer.from(credential.value).toString("base64"); return { headers: { Authorization: `Basic ${encoded}` }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value, encoded], }; } if (auth.type === "api-key-header") { const credential = await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.key, ctx, runtime, connectionId: args.connectionId, }); return { headers: { [auth.header]: credential.value }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "google-service-account") { const token = await getGoogleServiceAccountToken(auth.scopes, runtime, ctx); return { headers: { Authorization: `Bearer ${token}` }, credentialSources: [ { key: "GOOGLE_APPLICATION_CREDENTIALS_JSON", provider: config.id, source: runtime.localCredentialSource ?? "app_local", }, ], secretValues: [token], }; } if (auth.type === "oauth-bearer") { const oauthProvider = runtime.oauthProviderOverrides?.[config.id] ?? auth.oauthProvider; const credential = auth.workspaceProvider && args.connectionId ? await resolveConnectionBoundOAuthBearerToken({ auth: { ...auth, oauthProvider }, runtime, ctx, workspaceProvider: auth.workspaceProvider, connectionId: args.connectionId, accountId: args.accountId, }) : await resolveOAuthBearerToken({ auth: { ...auth, oauthProvider }, ctx, accountId: args.accountId, }); return { headers: { Authorization: `Bearer ${credential.value}` }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "oauth-bearer-or-api-key-header") { const explicitConnectionFallback = Boolean(args.connectionId?.trim()) && Boolean(runtime.resolveCredential); const resolvedFallback = explicitConnectionFallback ? await resolveHybridFallbackCredential({ auth, config, runtime, ctx, args, }) : null; if (resolvedFallback) { return { headers: { [auth.header]: resolvedFallback.value }, credentialSources: [omitCredentialValue(resolvedFallback)], secretValues: [resolvedFallback.value], }; } const workspaceCredential = await resolveOptionalConnectionBoundOAuthBearerToken({ auth: { type: "oauth-bearer", oauthProvider: auth.oauthProvider, tokenLabel: auth.tokenLabel, }, runtime, ctx, connectionId: args.connectionId, accountId: args.accountId, workspaceProvider: auth.workspaceProvider, allowUnbound: true, }); if (workspaceCredential) { return { headers: { Authorization: `Bearer ${workspaceCredential.value}` }, credentialSources: [omitCredentialValue(workspaceCredential)], secretValues: [workspaceCredential.value], }; } if (!explicitConnectionFallback) { const resolvedFallback = await resolveHybridFallbackCredential({ auth, config, runtime, ctx, args, }); if (resolvedFallback) { return { headers: { [auth.header]: resolvedFallback.value }, credentialSources: [omitCredentialValue(resolvedFallback)], secretValues: [resolvedFallback.value], }; } } const credential = await resolveAnyCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, keys: auth.fallbackKeys ?? [auth.key], ctx, runtime, }); return { headers: { [auth.header]: credential.value }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "oauth-bearer-or-bearer-key") { const explicitConnectionFallback = Boolean(args.connectionId?.trim()) && Boolean(runtime.resolveCredential); const resolvedFallback = explicitConnectionFallback ? await resolveHybridFallbackCredential({ auth, config, runtime, ctx, args, }) : null; if (resolvedFallback) { return { headers: { Authorization: `Bearer ${resolvedFallback.value}` }, credentialSources: [omitCredentialValue(resolvedFallback)], secretValues: [resolvedFallback.value], }; } const workspaceCredential = await resolveOptionalConnectionBoundOAuthBearerToken({ auth: { type: "oauth-bearer", oauthProvider: auth.oauthProvider, tokenLabel: auth.tokenLabel, }, runtime, ctx, connectionId: args.connectionId, accountId: args.accountId, workspaceProvider: auth.workspaceProvider, allowUnbound: true, }); if (workspaceCredential) { return { headers: { Authorization: `Bearer ${workspaceCredential.value}` }, credentialSources: [omitCredentialValue(workspaceCredential)], secretValues: [workspaceCredential.value], }; } if (!explicitConnectionFallback) { const resolvedFallback = await resolveHybridFallbackCredential({ auth, config, runtime, ctx, args, }); if (resolvedFallback) { return { headers: { Authorization: `Bearer ${resolvedFallback.value}` }, credentialSources: [omitCredentialValue(resolvedFallback)], secretValues: [resolvedFallback.value], }; } } const credential = await resolveAnyCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, keys: auth.fallbackKeys ?? [auth.key], ctx, runtime, }); return { headers: { Authorization: `Bearer ${credential.value}` }, credentialSources: [omitCredentialValue(credential)], secretValues: [credential.value], }; } if (auth.type === "oauth-bearer-or-basic") { const workspaceCredential = await resolveOptionalConnectionBoundOAuthBearerToken({ auth: { type: "oauth-bearer", oauthProvider: auth.oauthProvider, tokenLabel: auth.tokenLabel, }, runtime, ctx, connectionId: args.connectionId, accountId: args.accountId, workspaceProvider: auth.workspaceProvider, allowUnbound: true, }); if (workspaceCredential) { return { headers: { Authorization: `Bearer ${workspaceCredential.value}` }, credentialSources: [omitCredentialValue(workspaceCredential)], secretValues: [workspaceCredential.value], }; } const username = await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.usernameKey, ctx, runtime, connectionId: args.connectionId, }); const password = await resolveRequiredCredential({ provider: config.id, workspaceProvider: auth.workspaceProvider, key: auth.passwordKey, ctx, runtime, connectionId: args.connectionId, }); const encoded = Buffer.from(`${username.value}:${password.value}`).toString( "base64", ); return { headers: { Authorization: `Basic ${encoded}` }, credentialSources: [ omitCredentialValue(username), omitCredentialValue(password), ], secretValues: [username.value, password.value, encoded], }; } const bearer = await resolveCredentialValue({ config, runtime, ctx, key: "PROMETHEUS_BEARER_TOKEN", args, }); if (bearer) { return { headers: { Authorization: `Bearer ${bearer}` }, credentialSources: [ { key: "PROMETHEUS_BEARER_TOKEN", provider: config.id, source: runtime.localCredentialSource ?? "app_local", }, ], secretValues: [bearer], }; } const username = await resolveCredentialValue({ config, runtime, ctx, key: "PROMETHEUS_USERNAME", args, }); const password = await resolveCredentialValue({ config, runtime, ctx, key: "PROMETHEUS_PASSWORD", args, }); if (username && password) { const encoded = Buffer.from(`${username}:${password}`).toString("base64"); return { headers: { Authorization: `Basic ${encoded}` }, credentialSources: [ { key: "PROMETHEUS_USERNAME", provider: config.id, source: runtime.localCredentialSource ?? "app_local", }, { key: "PROMETHEUS_PASSWORD", provider: config.id, source: runtime.localCredentialSource ?? "app_local", }, ], secretValues: [username, password, encoded], }; } return emptyAuth(); } function emptyAuth(): ResolvedAuth { return { headers: {}, credentialSources: [], secretValues: [] }; } async function resolveHybridFallbackCredential(options: { auth: | Extract | Extract; config: ProviderApiConfig; runtime: ProviderApiRuntimeOptions; ctx: CredentialContext; args: ProviderApiRequestArgs; }): Promise { // App-specific resolvers keep existing provider credentials working when a // caller explicitly selects a connection or account. if (!options.runtime.resolveCredential) return null; return resolveOptionalCredential({ provider: options.config.id, workspaceProvider: options.auth.workspaceProvider, key: options.auth.key, ctx: options.ctx, runtime: options.runtime, connectionId: options.args.connectionId, }); } async function resolveAnyCredential(options: { provider: ProviderApiId; workspaceProvider: string | undefined; keys: readonly string[]; ctx: CredentialContext; runtime: ProviderApiRuntimeOptions; connectionId?: string | null; }): Promise { for (const key of options.keys) { const credential = await resolveOptionalCredential({ ...options, key }); if (credential?.value) return credential; } const scopeGap = await describeCredentialScopeGap( options.keys, options.ctx, ).catch(() => null); throw new Error( `${options.provider} credential not configured. Tried: ${options.keys.join( ", ", )}${scopeGap ? `. ${scopeGap}` : ""}`, ); } async function resolveRequiredCredential(options: { provider: ProviderApiId; workspaceProvider: string | undefined; key: string; ctx: CredentialContext; runtime: ProviderApiRuntimeOptions; connectionId?: string | null; }): Promise { const credential = await resolveOptionalCredential(options); if (!credential?.value) throw new Error(`${options.key} not configured`); return credential; } async function resolveOptionalCredential(options: { provider: ProviderApiId; workspaceProvider: string | undefined; key: string; ctx: CredentialContext; runtime: ProviderApiRuntimeOptions; connectionId?: string | null; }): Promise { const localCredentialSource = options.runtime.localCredentialSource ?? "app_local"; const lookup: ProviderApiCredentialLookupOptions = { appId: options.runtime.appId, provider: options.provider, key: options.key, ctx: options.ctx, workspaceProvider: options.workspaceProvider, connectionId: options.connectionId, localCredentialSource, }; if (options.runtime.resolveCredential) { return options.runtime.resolveCredential(lookup); } return defaultProviderApiCredentialResolver(lookup); } function omitCredentialValue( credential: ProviderApiResolvedCredential, ): Omit { const { value: _value, ...rest } = credential; return rest; } const googleServiceTokenCache = new Map< string, { token: string; expiresAt: number } >(); async function getGoogleServiceAccountToken( scopes: readonly string[], runtime: ProviderApiRuntimeOptions, ctx: CredentialContext, ): Promise { const cacheKey = createHash("sha256") .update( `${runtime.appId}:${ctx.orgId ?? ctx.userEmail}:${scopes.join(" ")}`, ) .digest("hex"); const cached = googleServiceTokenCache.get(cacheKey); if (cached && Date.now() < cached.expiresAt - 30_000) return cached.token; const credsJson = await resolveCredentialValue({ config: getProviderApiConfig("gcloud", runtime.providerOverrides), runtime, ctx, key: "GOOGLE_APPLICATION_CREDENTIALS_JSON", args: { provider: "gcloud", path: "/" }, }); if (!credsJson) { throw new Error("GOOGLE_APPLICATION_CREDENTIALS_JSON not configured"); } let creds: { type?: string; client_email?: string; private_key?: string; token_uri?: string; }; try { creds = JSON.parse(credsJson) as typeof creds; } catch { throw new Error( "GOOGLE_APPLICATION_CREDENTIALS_JSON is not valid JSON. Upload a service account JSON key.", ); } if (!creds.client_email || !creds.private_key) { throw new Error( "GOOGLE_APPLICATION_CREDENTIALS_JSON must be a service account JSON key.", ); } const now = Math.floor(Date.now() / 1000); const aud = creds.token_uri || "https://oauth2.googleapis.com/token"; const jwt = await signRs256Jwt( { iss: creds.client_email, scope: scopes.join(" "), aud, iat: now, exp: now + 3600, }, creds.private_key, ); const res = await fetch(aud, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: jwt, }), }); if (!res.ok) { throw new Error(`Google OAuth error ${res.status}: ${await res.text()}`); } const data = (await res.json()) as { access_token: string; expires_in?: number; }; googleServiceTokenCache.set(cacheKey, { token: data.access_token, expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000, }); return data.access_token; } async function resolveOAuthBearerToken(options: { auth: Extract; ctx: CredentialContext; accountId?: string | null; ownerEmail?: string | null; salesforceLoginUrl?: string | null; }): Promise { const ownerEmail = options.ownerEmail?.trim() || options.ctx.userEmail; const accounts = await listOAuthAccountsByOwner( options.auth.oauthProvider, ownerEmail, ); if (accounts.length === 0) { throw new Error( `${options.auth.tokenLabel} is not connected for ${ownerEmail}.`, ); } const accountId = options.accountId?.trim(); const account = accountId ? accounts.find((entry) => entry.accountId === accountId) : accounts[0]; if (!account) { throw new Error( `${options.auth.tokenLabel} account ${accountId} is not available to ${ownerEmail}.`, ); } const tokens = account.tokens as OAuthTokens; const token = await getValidOAuthAccessToken({ oauthProvider: options.auth.oauthProvider, accountId: account.accountId, ownerEmail, tokens, salesforceLoginUrl: options.salesforceLoginUrl, }); return { key: `${options.auth.oauthProvider.toUpperCase()}_OAUTH_TOKEN`, value: token, source: "oauth_token", provider: options.auth.oauthProvider, accountId: account.accountId, accountLabel: account.displayName, }; } async function resolveConnectionBoundOAuthBearerToken(options: { auth: Extract; runtime: ProviderApiRuntimeOptions; ctx: CredentialContext; workspaceProvider: string; connectionId?: string | null; accountId?: string | null; }): Promise { const credential = await resolveOptionalConnectionBoundOAuthBearerToken(options); if (credential) return credential; throw new Error( `${options.auth.tokenLabel} requires an available workspace connection.`, ); } async function resolveOptionalConnectionBoundOAuthBearerToken(options: { auth: Extract; runtime: ProviderApiRuntimeOptions; ctx: CredentialContext; workspaceProvider: string; connectionId?: string | null; accountId?: string | null; allowUnbound?: boolean; }): Promise { const requestedConnectionId = options.connectionId?.trim(); let resolved: Awaited>; try { resolved = await resolveWorkspaceConnectionForApp({ appId: options.runtime.appId, provider: options.workspaceProvider, connectionId: requestedConnectionId, requireConnected: true, }); } catch (error) { if ( error instanceof Error && error.message === "Workspace connections require an authenticated user." ) { return null; } throw error; } if (!resolved.available || !resolved.connection) { if (requestedConnectionId) throw new Error(resolved.reason); return null; } const connectionAccountId = resolved.connection.accountId?.trim(); if (!connectionAccountId) { if (options.allowUnbound) return null; throw new Error( `${options.auth.tokenLabel} workspace connection ${resolved.connection.id} is missing its OAuth account binding.`, ); } const requestedAccountId = options.accountId?.trim(); if (requestedAccountId && requestedAccountId !== connectionAccountId) { throw new Error( `${options.auth.tokenLabel} account ${requestedAccountId} does not match workspace connection ${requestedConnectionId}.`, ); } const credential = await resolveOAuthBearerToken({ auth: options.auth, ctx: options.ctx, accountId: connectionAccountId, ownerEmail: resolved.connection.ownerEmail, salesforceLoginUrl: options.auth.oauthProvider === "salesforce" && typeof resolved.connection.config?.salesforceLoginUrl === "string" ? resolved.connection.config.salesforceLoginUrl : null, }); return { ...credential, connectionId: resolved.connection.id, connectionLabel: resolved.connection.label, }; } async function getValidOAuthAccessToken(options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; salesforceLoginUrl?: string | null; }): Promise { const accessToken = options.tokens.access_token ?? options.tokens.accessToken ?? ""; if (!accessToken) { throw new Error( `${options.oauthProvider} OAuth account has no access token.`, ); } const expiresAt = options.tokens.expiry_date ?? options.tokens.expiresAt; if ( !expiresAt || !Number.isFinite(expiresAt) || expiresAt > Date.now() + 60_000 ) { return accessToken; } const refreshToken = options.tokens.refresh_token ?? options.tokens.refreshToken; if (!refreshToken) return accessToken; if ( options.oauthProvider === "google" || options.oauthProvider === "google-docs" ) { return refreshGoogleOAuthToken(options, refreshToken); } if (options.oauthProvider === "figma") { return refreshFigmaOAuthToken(options, refreshToken); } if (options.oauthProvider === "notion") { return refreshNotionOAuthToken(options, refreshToken); } if (options.oauthProvider === "github") { return refreshGitHubOAuthToken(options, refreshToken); } if (options.oauthProvider === "hubspot") { return refreshHubSpotOAuthToken(options, refreshToken); } if (options.oauthProvider === "salesforce") { return refreshSalesforceOAuthToken(options, refreshToken); } if (options.oauthProvider === "jira") { return refreshJiraOAuthToken(options, refreshToken); } if (options.oauthProvider === "sentry") { return refreshSentryOAuthToken(options, refreshToken); } throw new Error( `${options.oauthProvider} OAuth token is expired and automatic refresh is not configured for provider-api.`, ); } async function refreshFigmaOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const [clientId, clientSecret] = await Promise.all([ resolveSecret("FIGMA_CLIENT_ID"), resolveSecret("FIGMA_CLIENT_SECRET"), ]); if (!clientId || !clientSecret) { throw new Error( "FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET are required to refresh Figma OAuth tokens.", ); } const { response, data } = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://api.figma.com/v1/oauth/token", { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, }), }, "Figma", ); if (!response.ok || !data.access_token) { throw new Error(`Figma OAuth refresh failed (${response.status}).`); } const updated = { ...options.tokens, access_token: data.access_token, token_type: data.token_type ?? "bearer", expiry_date: Date.now() + (data.expires_in ?? 3600) * 1_000, refresh_token: data.refresh_token ?? refreshToken, }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } async function refreshNotionOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const [clientId, clientSecret] = await Promise.all([ resolveSecret("NOTION_CLIENT_ID"), resolveSecret("NOTION_CLIENT_SECRET"), ]); if (!clientId || !clientSecret) { throw new Error( "NOTION_CLIENT_ID and NOTION_CLIENT_SECRET are required to refresh Notion OAuth tokens.", ); } const { response, data } = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://api.notion.com/v1/oauth/token", { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, "Content-Type": "application/json", "Notion-Version": "2026-03-11", }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, }), }, "Notion", ); if (!response.ok || !data.access_token) { throw new Error(`Notion OAuth refresh failed (${response.status}).`); } const updated = { ...options.tokens, access_token: data.access_token, token_type: data.token_type ?? "bearer", expiry_date: Date.now() + (data.expires_in ?? 3600) * 1_000, refresh_token: data.refresh_token ?? refreshToken, }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } async function refreshGitHubOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const credentials = await resolveOAuthClientCredentialCandidates("GITHUB"); if (!credentials.length) { throw new Error( "GITHUB_CLIENT_ID/SECRET not set for GitHub OAuth refresh.", ); } let response: Response | null = null; let data: { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; } = {}; for (const credential of credentials) { const result = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: credential.clientId, client_secret: credential.clientSecret, grant_type: "refresh_token", refresh_token: refreshToken, }), }, "GitHub", ); response = result.response; data = result.data; if (response.ok && data.access_token) break; } if (!response?.ok || !data.access_token) { throw new Error(`GitHub OAuth refresh failed (${response?.status ?? 0}).`); } const updated = { ...options.tokens, access_token: data.access_token, refresh_token: data.refresh_token ?? refreshToken, token_type: data.token_type ?? options.tokens.token_type, ...(Number.isFinite(data.expires_in) && (data.expires_in ?? 0) > 0 ? { expiry_date: Date.now() + (data.expires_in as number) * 1_000 } : {}), }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } async function saveOAuthTokensAndLinkedJiraAccounts(options: { options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }; previousRefreshToken: string; updated: OAuthTokens; }): Promise { const serializedTokens = options.updated as unknown as Record< string, unknown >; await saveOAuthTokens( options.options.oauthProvider, options.options.accountId, serializedTokens, options.options.ownerEmail, ); const accounts = await listOAuthAccountsByOwner( options.options.oauthProvider, options.options.ownerEmail, ); for (const account of accounts) { if (account.accountId === options.options.accountId) continue; const accountRefreshToken = (account.tokens.refresh_token as string | undefined) ?? (account.tokens.refreshToken as string | undefined); if (accountRefreshToken !== options.previousRefreshToken) continue; await saveOAuthTokens( options.options.oauthProvider, account.accountId, serializedTokens, options.options.ownerEmail, ); } } async function refreshHubSpotOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const credentials = await resolveOAuthClientCredentialCandidates("HUBSPOT"); if (!credentials.length) { throw new Error( "HUBSPOT_CLIENT_ID/SECRET not set for HubSpot OAuth refresh.", ); } let response: Response | null = null; let data: { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; } = {}; for (const credential of credentials) { const result = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://api.hubapi.com/oauth/v3/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", client_id: credential.clientId, client_secret: credential.clientSecret, refresh_token: refreshToken, }), }, "HubSpot", ); response = result.response; data = result.data; if (response.ok && data.access_token) break; } if (!response?.ok || !data.access_token) { throw new Error(`HubSpot OAuth refresh failed (${response?.status ?? 0}).`); } const updated = { ...options.tokens, access_token: data.access_token, refresh_token: data.refresh_token ?? refreshToken, token_type: data.token_type ?? options.tokens.token_type, ...(Number.isFinite(data.expires_in) && (data.expires_in ?? 0) > 0 ? { expiry_date: Date.now() + (data.expires_in as number) * 1_000 } : {}), }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } async function refreshSalesforceOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; salesforceLoginUrl?: string | null; }, refreshToken: string, ): Promise { const credentials = await resolveOAuthClientCredentialCandidates("SALESFORCE"); if (!credentials.length) { throw new Error( "SALESFORCE_CLIENT_ID/SECRET not set for Salesforce OAuth refresh.", ); } let response: Response | null = null; let data: { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; } = {}; for (const credential of credentials) { const result = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( salesforceOAuthRefreshUrl(options.salesforceLoginUrl), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", client_id: credential.clientId, client_secret: credential.clientSecret, refresh_token: refreshToken, }), }, "Salesforce", ); response = result.response; data = result.data; if (response.ok && data.access_token) break; } if (!response?.ok || !data.access_token) { throw new Error( `Salesforce OAuth refresh failed (${response?.status ?? 0}).`, ); } const updated = { ...options.tokens, access_token: data.access_token, refresh_token: data.refresh_token ?? refreshToken, token_type: data.token_type ?? options.tokens.token_type, ...(Number.isFinite(data.expires_in) && (data.expires_in ?? 0) > 0 ? { expiry_date: Date.now() + (data.expires_in as number) * 1_000 } : {}), }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } function salesforceOAuthRefreshUrl(value: string | null | undefined): string { const loginUrl = value?.trim() || "https://login.salesforce.com"; try { const url = new URL(loginUrl); if ( url.protocol !== "https:" || url.username || url.password || url.port || url.pathname !== "/" || url.search || url.hash || (url.hostname !== "login.salesforce.com" && url.hostname !== "test.salesforce.com") ) { throw new Error("Invalid Salesforce OAuth login URL."); } return `${url.origin}/services/oauth2/token`; } catch { throw new Error("Invalid Salesforce OAuth login URL."); } } async function refreshJiraOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const credentials = await resolveOAuthClientCredentialCandidates("JIRA"); if (!credentials.length) { throw new Error("JIRA_CLIENT_ID/SECRET not set for Jira OAuth refresh."); } let response: Response | null = null; let data: { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; } = {}; for (const credential of credentials) { const result = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://auth.atlassian.com/oauth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", client_id: credential.clientId, client_secret: credential.clientSecret, refresh_token: refreshToken, }), }, "Jira", ); response = result.response; data = result.data; if (response.ok && data.access_token) break; } if (!response?.ok || !data.access_token) { throw new Error(`Jira OAuth refresh failed (${response?.status ?? 0}).`); } const updated = { ...options.tokens, access_token: data.access_token, refresh_token: data.refresh_token ?? refreshToken, token_type: data.token_type ?? options.tokens.token_type, ...(Number.isFinite(data.expires_in) && (data.expires_in ?? 0) > 0 ? { expiry_date: Date.now() + (data.expires_in as number) * 1_000 } : {}), }; await saveOAuthTokensAndLinkedJiraAccounts({ options, previousRefreshToken: refreshToken, updated, }); return data.access_token; } async function refreshSentryOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const credentials = await resolveOAuthClientCredentialCandidates("SENTRY"); if (!credentials.length) { throw new Error( "SENTRY_CLIENT_ID/SECRET not set for Sentry OAuth refresh.", ); } let response: Response | null = null; let data: { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; } = {}; for (const credential of credentials) { const result = await fetchBoundedOAuthRefreshJson<{ access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string; }>( "https://sentry.io/oauth/token/", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", client_id: credential.clientId, client_secret: credential.clientSecret, refresh_token: refreshToken, }), }, "Sentry", ); response = result.response; data = result.data; if (response.ok && data.access_token) break; } if (!response?.ok || !data.access_token) { throw new Error(`Sentry OAuth refresh failed (${response?.status ?? 0}).`); } const updated = { ...options.tokens, access_token: data.access_token, refresh_token: data.refresh_token ?? refreshToken, token_type: data.token_type ?? options.tokens.token_type, ...(Number.isFinite(data.expires_in) && (data.expires_in ?? 0) > 0 ? { expiry_date: Date.now() + (data.expires_in as number) * 1_000 } : {}), }; await saveOAuthTokens( options.oauthProvider, options.accountId, updated as unknown as Record, options.ownerEmail, ); return data.access_token; } const OAUTH_REFRESH_TIMEOUT_MS = 10_000; const OAUTH_REFRESH_MAX_BYTES = 256 * 1024; async function fetchBoundedOAuthRefreshJson>( url: string, init: RequestInit, providerLabel: string, ): Promise<{ response: Response; data: T }> { let response: Response; try { response = await fetch(url, { ...init, signal: AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS), }); } catch { throw new Error(`${providerLabel} OAuth refresh request failed.`); } const declaredLength = Number(response.headers.get("content-length")); if ( Number.isFinite(declaredLength) && declaredLength > OAUTH_REFRESH_MAX_BYTES ) { throw new Error( `${providerLabel} OAuth refresh response exceeded the size limit.`, ); } if (!response.body) return { response, data: {} as T }; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let length = 0; try { while (true) { const chunk = await reader.read(); if (chunk.done) break; length += chunk.value.byteLength; if (length > OAUTH_REFRESH_MAX_BYTES) { await reader.cancel(); break; } chunks.push(chunk.value); } } catch { throw new Error(`${providerLabel} OAuth refresh request failed.`); } if (length > OAUTH_REFRESH_MAX_BYTES) { throw new Error( `${providerLabel} OAuth refresh response exceeded the size limit.`, ); } try { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); return { response, data: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as T) : ({} as T), }; } catch { return { response, data: {} as T }; } } async function refreshGoogleOAuthToken( options: { oauthProvider: string; accountId: string; ownerEmail: string; tokens: OAuthTokens; }, refreshToken: string, ): Promise { const [scopedClientId, scopedClientSecret] = await Promise.all([ resolveSecret("GOOGLE_CLIENT_ID"), resolveSecret("GOOGLE_CLIENT_SECRET"), ]); const credentialCandidates = dedupeGoogleOAuthCredentials([ ...(scopedClientId && scopedClientSecret ? [{ clientId: scopedClientId, clientSecret: scopedClientSecret }] : []), ...resolveGoogleProviderCredentialCandidates(), ]); if (!credentialCandidates.length) { throw new Error( "GOOGLE_CLIENT_ID/SECRET not set for Google OAuth refresh.", ); } let data: { access_token?: string; expires_in?: number; token_type?: string; scope?: string; error?: string; error_description?: string; } | null = null; let lastStatusText = "refresh failed"; for (const credentials of credentialCandidates) { const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ refresh_token: refreshToken, client_id: credentials.clientId, client_secret: credentials.clientSecret, grant_type: "refresh_token", }), }); lastStatusText = res.statusText; data = (await res.json()) as { access_token?: string; expires_in?: number; token_type?: string; scope?: string; error?: string; error_description?: string; }; if (res.ok && data.access_token) break; if (data.error && PERMANENT_GOOGLE_OAUTH_REFRESH_ERRORS.has(data.error)) { continue; } const detail = data.error_description ?? data.error ?? lastStatusText; throw new Error(`Google OAuth refresh failed: ${detail}`); } if (!data?.access_token) { if (data?.error && PERMANENT_GOOGLE_OAUTH_REFRESH_ERRORS.has(data.error)) { await deleteOAuthTokens(options.oauthProvider, options.accountId); } const detail = data?.error_description ?? data?.error ?? lastStatusText; throw new Error(`Google OAuth refresh failed: ${detail}`); } const merged: OAuthTokens = { ...options.tokens, access_token: data.access_token, expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, token_type: data.token_type ?? options.tokens.token_type, scope: data.scope ?? options.tokens.scope, }; await saveOAuthTokens( options.oauthProvider, options.accountId, merged as unknown as Record, options.ownerEmail, ); return data.access_token; } function dedupeGoogleOAuthCredentials( candidates: Array<{ clientId: string; clientSecret: string }>, ): Array<{ clientId: string; clientSecret: string }> { const seen = new Set(); return candidates.filter((candidate) => { if (seen.has(candidate.clientId)) return false; seen.add(candidate.clientId); return true; }); } async function resolveOAuthClientCredentialCandidates( provider: string, ): Promise> { const [clientId, clientSecret, integrationClientId, integrationClientSecret] = await Promise.all([ resolveSecret(`${provider}_CLIENT_ID`), resolveSecret(`${provider}_CLIENT_SECRET`), resolveSecret(`${provider}_INTEGRATION_CLIENT_ID`), resolveSecret(`${provider}_INTEGRATION_CLIENT_SECRET`), ]); const candidates = [ integrationClientId && integrationClientSecret ? { clientId: integrationClientId, clientSecret: integrationClientSecret } : null, clientId && clientSecret ? { clientId, clientSecret } : null, ].filter((value): value is { clientId: string; clientSecret: string } => Boolean(value), ); if (provider === "SALESFORCE") { const [consumerKey, consumerSecret] = await Promise.all([ resolveSecret("SALESFORCE_CONSUMER_KEY"), resolveSecret("SALESFORCE_CONSUMER_SECRET"), ]); if (consumerKey && consumerSecret) { candidates.push({ clientId: consumerKey, clientSecret: consumerSecret }); } } const seen = new Set(); return candidates.filter((candidate) => { if (seen.has(candidate.clientId)) return false; seen.add(candidate.clientId); return true; }); } function normalizeMethod( method: ProviderApiMethod | undefined, ): ProviderApiMethod { const normalized = String(method || "GET").toUpperCase(); if ( normalized === "GET" || normalized === "POST" || normalized === "PUT" || normalized === "PATCH" || normalized === "DELETE" || normalized === "HEAD" ) { return normalized; } throw new Error(`Unsupported HTTP method: ${method}`); } function sanitizeOutboundHeaders(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const headers: Record = {}; for (const [name, rawValue] of Object.entries(value)) { const lower = name.toLowerCase(); if (!HEADER_NAME_RE.test(name) || BLOCKED_OUTBOUND_HEADERS.has(lower)) { continue; } if (rawValue === undefined || rawValue === null) continue; const headerValue = String(rawValue); if (/[\r\n]/.test(headerValue)) continue; headers[name] = headerValue; } return headers; } function prepareBody( body: unknown, headers: Record, ): BodyInit | undefined { if (body === undefined || body === null) return undefined; if (typeof body === "string") return body; const hasContentType = Object.keys(headers).some( (name) => name.toLowerCase() === "content-type", ); if (!hasContentType) headers["Content-Type"] = "application/json"; return JSON.stringify(body); } function runtimeOptionsAppId(runtime: ProviderApiRuntimeOptions): string { return runtime.appId || "app"; } async function fetchWithTimeout( optionsUrl: string, options: { method?: ProviderApiMethod; headers?: Record; body?: BodyInit; timeoutMs?: number; maxBytes?: number; secretValues?: string[]; quota?: ProviderApiFetchQuotaOptions; }, ): Promise { const runOnce = async (): Promise => { const controller = new AbortController(); const timeoutMs = clampTimeout(options.timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs); const method = options.method ?? "GET"; const secretValues = options.secretValues ?? []; try { const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined; const fetchOptions: RequestInit & { dispatcher?: unknown } = { method, headers: options.headers, body: options.body, signal: controller.signal, redirect: "manual", }; if (dispatcher) fetchOptions.dispatcher = dispatcher; try { return await fetchProviderResponse(optionsUrl, fetchOptions, { maxBytes: options.maxBytes, secretValues, }); } catch (error) { if (dispatcher && isDispatcherCompatibilityError(error)) { const fallbackOptions = { ...fetchOptions }; delete fallbackOptions.dispatcher; return await fetchProviderResponse(optionsUrl, fallbackOptions, { maxBytes: options.maxBytes, secretValues, }); } throw error; } } catch (error) { throw normalizeFetchError(error, { method, url: optionsUrl, timeoutMs, secretValues, }); } finally { clearTimeout(timeout); } }; if (options.quota) { return executeWithProviderQuota({ request: { identity: options.quota.identity, method: options.quota.method, target: options.quota.target, requestKey: options.quota.requestKey, }, execute: runOnce, inspect: (result) => ({ status: result.status, headers: result.headers, }), buildQuotaExhaustedResult: providerQuotaExhaustedResponse, }); } return runOnce(); } function providerQuotaExhaustedResponse( detail: ProviderQuotaExhaustedDetail, ): ProviderApiHttpResponse { const retryAfterSeconds = Math.max(0, Math.ceil(detail.retryAfterMs / 1000)); return { status: 429, statusText: "Provider quota cooldown", ok: false, elapsedMs: 0, headers: { "retry-after": String(retryAfterSeconds), "x-agent-native-provider-quota": "exhausted", }, contentType: "application/json", size: 0, truncated: false, json: { error: "provider_quota_exhausted", provider: detail.providerId, message: `Provider API quota is cooling down for ${detail.providerId}. ` + `Retry after ${detail.retryAt}.`, retryAt: detail.retryAt, retryAfterMs: detail.retryAfterMs, reason: detail.reason, method: detail.method, target: detail.target, }, quota: { exhausted: true, providerId: detail.providerId, retryAfterMs: detail.retryAfterMs, retryAt: detail.retryAt, reason: detail.reason, }, }; } async function fetchProviderResponse( url: string, fetchOptions: RequestInit & { dispatcher?: unknown }, options: { maxBytes?: number; secretValues: string[]; }, ): Promise { const startedAt = Date.now(); const res = await fetch(url, fetchOptions); const elapsedMs = Date.now() - startedAt; const rawText = await readResponseTextWithLimit( res, clampMaxBytes(options.maxBytes), ); const redactedText = redactString(rawText.text, options.secretValues); const parsed = tryParseJson(redactedText); return { status: res.status, statusText: res.statusText, ok: res.ok, elapsedMs, headers: redactSecrets(headersToObject(res.headers), options.secretValues), contentType: res.headers.get("content-type") ?? null, size: rawText.size, truncated: rawText.truncated, text: parsed === undefined ? redactedText : undefined, json: parsed, }; } function isDispatcherCompatibilityError(error: unknown): boolean { const err = error as { message?: unknown; cause?: { code?: unknown; message?: unknown }; }; const code = typeof err?.cause?.code === "string" ? err.cause.code : ""; const detail = [ typeof err?.message === "string" ? err.message : "", typeof err?.cause?.message === "string" ? err.cause.message : "", ] .join(" ") .toLowerCase(); return ( code === "UND_ERR_INVALID_ARG" && detail.includes("invalid onrequeststart method") ); } function normalizeFetchError( error: unknown, options: { method: ProviderApiMethod; url: string; timeoutMs: number; secretValues: string[]; }, ): Error { const target = describeProviderRequestTarget( options.url, options.secretValues, ); const err = error as { name?: unknown; message?: unknown; cause?: { code?: unknown; message?: unknown }; }; if (err?.name === "AbortError") { return new Error( `Provider API request timed out after ${options.timeoutMs}ms: ${options.method} ${target}`, { cause: error }, ); } const causeCode = typeof err?.cause?.code === "string" && err.cause.code ? ` (${err.cause.code})` : ""; const detail = typeof err?.cause?.message === "string" && err.cause.message ? err.cause.message : typeof err?.message === "string" && err.message ? err.message : String(error); return new Error( `Provider API request failed${causeCode}: ${options.method} ${target}: ${redactString(detail, options.secretValues)}`, { cause: error }, ); } function describeProviderRequestTarget( rawUrl: string, secretValues: string[], ): string { try { const url = new URL(rawUrl); return redactString( `${url.host}${url.pathname}${url.search}`, secretValues, ); } catch { return redactString(rawUrl, secretValues); } } async function readResponseTextWithLimit( response: Response, maxBytes: number, ): Promise<{ text: string; truncated: boolean; size: number }> { const contentLength = response.headers.get("content-length"); if (contentLength && Number(contentLength) > maxBytes) { return { text: `(response too large - ${contentLength} bytes, max ${maxBytes})`, truncated: true, size: Number(contentLength), }; } const buffer = await response.arrayBuffer(); const size = buffer.byteLength; const bytes = new Uint8Array(buffer.slice(0, maxBytes)); return { text: new TextDecoder().decode(bytes), truncated: size > maxBytes, size, }; } function headersToObject(headers: Headers): Record { const result: Record = {}; headers.forEach((value, key) => { if (key.toLowerCase() !== "set-cookie") result[key] = value; }); return result; } function tryParseJson(text: string): unknown | undefined { const trimmed = text.trim(); if (!trimmed || !/^[{[]/.test(trimmed)) return undefined; try { return JSON.parse(trimmed); } catch { return undefined; } } function redactSecrets(value: T, secretValues: string[]): T { if (secretValues.length === 0) return value; if (typeof value === "string") return redactString(value, secretValues) as T; if (Array.isArray(value)) { return value.map((item) => redactSecrets(item, secretValues)) as T; } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, entry]) => [ key, redactSecrets(entry, secretValues), ]), ) as T; } return value; } function redactString(text: string, secretValues: string[]): string { let output = text; for (const secret of [...secretValues].sort((a, b) => b.length - a.length)) { if (!secret) continue; output = output.split(secret).join("[redacted]"); try { output = output.split(encodeURIComponent(secret)).join("[redacted]"); } catch {} } return output; } function mergeQueryObjects( base: unknown, extra: Record, ): unknown { if (!base) return extra; if (typeof base === "string") { const params = new URLSearchParams(base.replace(/^\?/, "")); for (const [key, value] of Object.entries(extra)) { params.set(key, value); } return params.toString(); } if (typeof base === "object" && !Array.isArray(base)) { return { ...(base as Record), ...extra }; } return extra; } function setValueAtPath(base: unknown, path: string, value: unknown): unknown { const root = base && typeof base === "object" && !Array.isArray(base) ? { ...(base as Record) } : {}; const parts = path.split(".").filter(Boolean); if (!parts.length) return root; let current: Record = root; for (const part of parts.slice(0, -1)) { const existing = current[part]; const next = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...(existing as Record) } : {}; current[part] = next; current = next; } current[parts[parts.length - 1]!] = value; return root; } function clampTimeout(timeoutMs: number | undefined): number { if (!Number.isFinite(timeoutMs)) return DEFAULT_TIMEOUT_MS; return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Math.floor(timeoutMs!))); } function clampMaxBytes(maxBytes: number | undefined): number { if (!Number.isFinite(maxBytes)) return DEFAULT_MAX_BYTES; return Math.max(1_000, Math.min(MAX_MAX_BYTES, Math.floor(maxBytes!))); } /** Resolve a dot-path from a parsed JSON object, e.g. "meta.next_cursor". */ function dotGet(obj: unknown, path: string): unknown { if (!path) return obj; let current: unknown = obj; for (const key of path.split(".")) { if (current === null || typeof current !== "object") return undefined; current = (current as Record)[key]; } return current; } /** * Handle saveToFile: write the full provider-api response body to a workspace * file and return a compact summary. */ async function handleSaveToFile( filePath: string, responseText: string, contentType: string | null, status: number, ): Promise { const { writeWorkspaceFile, SAVE_TO_FILE_MAX_BYTES: maxSaveBytes } = await import("../workspace-files/store.js"); const { getRequestOrgId, getRequestUserEmail } = await import("../server/request-context.js"); const orgId = getRequestOrgId(); const email = getRequestUserEmail(); const scope = orgId ? { scope: "org" as const, scopeId: orgId } : email ? { scope: "user" as const, scopeId: email } : null; if (!scope) { throw new Error( "saveToFile requires an authenticated request context (no user email or orgId found).", ); } const mimeType = contentType?.split(";")[0].trim() ?? "text/plain"; await writeWorkspaceFile(scope, filePath, responseText, mimeType, { maxFileBytes: maxSaveBytes, }); const bytes = Buffer.byteLength(responseText, "utf8"); const preview = responseText.slice(0, 2000); return { savedToFile: true, savedTo: filePath, status, bytes, contentType: mimeType, preview: preview.length < responseText.length ? `${preview}…` : preview, }; } /** * Execute paginated requests, accumulating items across pages. * Returns the accumulated items array and the last response for metadata. */ async function fetchAllPages( config: FetchAllPagesConfig, executeOnePage: (extra?: { query?: Record; bodyCursor?: { path: string; value: string }; }) => Promise<{ text: string; contentType: string | null; status: number; ok: boolean; }>, ): Promise<{ items: unknown[]; pageCount: number; lastStatus: number; lastContentType: string | null; truncated: boolean; nextCursor: string | null; }> { const maxPages = Math.min( Number.isFinite(config.maxPages) && config.maxPages! > 0 ? config.maxPages! : 10, FETCH_ALL_PAGES_MAX, ); const items: unknown[] = []; let cursor: string | undefined; let pageCount = 0; let lastStatus = 0; let lastContentType: string | null = null; let truncated = false; if (!config.cursorParam && !config.cursorBodyPath) { throw new Error( "fetchAllPages requires cursorParam or cursorBodyPath to send the next cursor.", ); } if (config.cursorParam && config.cursorBodyPath) { throw new Error( "fetchAllPages accepts exactly one cursor method: cursorParam or cursorBodyPath.", ); } while (pageCount < maxPages) { const extra: { query?: Record; bodyCursor?: { path: string; value: string }; } = {}; if (cursor) { if (config.cursorParam) extra.query = { [config.cursorParam]: cursor }; if (config.cursorBodyPath) { extra.bodyCursor = { path: config.cursorBodyPath, value: cursor }; } } const page = await executeOnePage(pageCount > 0 ? extra : undefined); lastStatus = page.status; lastContentType = page.contentType; pageCount++; if (!page.ok) { const preview = page.text.replace(/\s+/g, " ").trim().slice(0, 500); throw new Error( `fetchAllPages request failed with HTTP ${page.status}${ preview ? `: ${preview}` : "" }`, ); } let body: unknown; try { body = JSON.parse(page.text); } catch { body = page.text; } // Extract items if (config.itemsPath) { const extracted = dotGet(body, config.itemsPath); if (Array.isArray(extracted)) { items.push(...extracted); } else if (extracted !== undefined) { items.push(extracted); } } else { items.push(body); } // Extract next cursor const nextCursor = dotGet(body, config.cursorPath); if ( !nextCursor || nextCursor === "" || nextCursor === null || nextCursor === cursor ) { truncated = false; break; } cursor = String(nextCursor); truncated = pageCount >= maxPages; } return { items, pageCount, lastStatus, lastContentType, truncated, nextCursor: truncated ? (cursor ?? null) : null, }; } function fingerprint(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } function base64UrlEncode(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i += 1) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function base64UrlEncodeString(value: string): string { return base64UrlEncode(new TextEncoder().encode(value)); } function pemToPkcs8(pem: string): Uint8Array { const body = pem .replace(/-----BEGIN [^-]+-----/g, "") .replace(/-----END [^-]+-----/g, "") .replace(/\s+/g, ""); const binary = atob(body); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { out[i] = binary.charCodeAt(i); } return out; } const keyCache = new Map>(); async function privateKeyCacheKey(privateKeyPem: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(privateKeyPem) as BufferSource, ); return `sha256:${base64UrlEncode(new Uint8Array(digest))}`; } async function importRs256Key(privateKeyPem: string): Promise { const cacheKey = await privateKeyCacheKey(privateKeyPem); let cached = keyCache.get(cacheKey); if (!cached) { cached = crypto.subtle.importKey( "pkcs8", pemToPkcs8(privateKeyPem) as BufferSource, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"], ); keyCache.set(cacheKey, cached); } return cached; } async function signRs256Jwt( payload: Record, privateKeyPem: string, ): Promise { const header = { alg: "RS256", typ: "JWT" }; const signingInput = `${base64UrlEncodeString( JSON.stringify(header), )}.${base64UrlEncodeString(JSON.stringify(payload))}`; const key = await importRs256Key(privateKeyPem); const signature = await crypto.subtle.sign( "RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput) as BufferSource, ); return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`; }