From 72b99622c8a0b87adc9ead73a7ac7a71080bdc72 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:21:58 -0800 Subject: [PATCH 01/83] fix(credentials): accept permission-scoped AgentMail keys at connect A valid AgentMail key that lacks inbox_read or is scoped to a pod or inbox gets a 403 coded missing_permission or forbidden on the inbox listing, and was refused as invalid. Accept a coded 403 as proof of the key, and keep rejecting the gateway's bare Forbidden and the key-naming 401 codes. --- .../validators/token-checks.test.ts | 44 +++++++++++++++++++ .../validators/token-checks.ts | 23 +++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 0e5ccc3b1bc..8a86d931549 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { + validateAgentMailServiceAccount, validateAlgoliaServiceAccount, validateAshbyServiceAccount, validateAzureDevOpsServiceAccount, @@ -166,6 +167,49 @@ describe('saved key verification for vendors with scoped keys', () => { }) }) +describe('AgentMail saved API key verification', () => { + it.each([ + ['lacks a permission the inbox listing needs', 'missing_permission'], + ['is scoped to a pod or inbox, not the organization', 'forbidden'], + ])('accepts a valid key that %s', async (_case, code) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ name: 'ForbiddenError', code, message: 'Forbidden' }, 403) + ) + ) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_scoped' })).resolves.toMatchObject( + { displayName: 'AgentMail' } + ) + }) + + it.each([ + ['the gateway refuses with a bare Forbidden', { message: 'Forbidden' }, 403], + ['the key is unknown', { code: 'unknown_api_key', message: 'Unauthorized' }, 401], + ['the key has expired', { code: 'api_key_expired', message: 'Unauthorized' }, 401], + ])('rejects the key when %s', async (_case, body, status) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(body, status))) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_bad' })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('reports an outage, not a bad key, for an uncoded gateway error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ message: 'Internal server error' }, 502)) + ) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_any' })).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) + describe('Ashby saved API key verification', () => { it('accepts a valid key that lacks the read permission for this endpoint', async () => { vi.stubGlobal( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index e4f57a094e4..37763638257 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -60,9 +60,30 @@ export const validateAffinityServiceAccount = tokenCheckValidator({ }), }) -/** Listing one AgentMail inbox answers 200 for a valid key and 403 otherwise. */ +/** AgentMail error codes that name the key itself as unusable. */ +const AGENTMAIL_REJECTED_KEY_CODES: ReadonlySet = new Set([ + 'missing_authorization', + 'invalid_token_type', + 'unknown_api_key', + 'api_key_expired', + 'unauthorized', +]) + +/** + * Listing one AgentMail inbox answers 200 for a valid key. An unknown or + * expired key gets a coded 401 or the gateway's bare `{"message":"Forbidden"}` + * 403; a valid key that lacks the inbox-read permission or is scoped to a pod + * or inbox gets a 403 coded `missing_permission` or `forbidden`, and can still + * run the tools its scope covers. + */ export const validateAgentMailServiceAccount = tokenCheckValidator({ serviceLabel: 'AgentMail', + forbiddenMeansScoped: true, + rejectsKey: (errorBody) => { + const { code, message } = toRecord(errorBody) + if (code === undefined) return message === 'Forbidden' + return AGENTMAIL_REJECTED_KEY_CODES.has(String(code)) + }, request: ({ apiToken }) => ({ url: 'https://api.agentmail.to/v0/inboxes?limit=1', headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, From 0bd94390392988971d3a27ffb5b236361953508c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:23:35 -0800 Subject: [PATCH 02/83] fix(credentials): offer Atlassian API tokens for the Bitbucket saved credential Bitbucket takes an Atlassian API token created for Bitbucket as a Bearer token, so the existing repository read already verifies one. Say so in the connect copy and setup guide, and note that app passwords no longer work. --- .../content/docs/integrations/bitbucket-service-account.mdx | 4 ++++ .../lib/credentials/token-service-accounts/descriptors.ts | 6 +++--- .../token-service-accounts/validators/token-checks.ts | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx index afa099a3e2a..19e379caebd 100644 --- a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx +++ b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx @@ -35,6 +35,10 @@ Admin access to the workspace, project, or repository the token is for. Workspac A repository access token can only reach its repository, and a project access token only the repositories in its project. Treat the token like a password; Sim encrypts it at rest. + +Sim also accepts an Atlassian API token created for Bitbucket (**Create API token with scopes**, then **Bitbucket**) with at least **Repositories: Read** (`read:repository:bitbucket`). An API token belongs to the person who created it and acts as them. Bitbucket app passwords no longer work: Atlassian stopped new app passwords on September 9, 2025 and set June 9, 2026 as the date existing ones stop working. See [Using API tokens](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/). + + ## Add the access token to Sim [#adding-the-access-token-to-sim] diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ebd5a56b5d7..8cbb8aa0915 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -1866,7 +1866,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< { id: 'apiToken', label: 'Access token', - placeholder: 'Paste a workspace, project, or repository access token', + placeholder: 'Paste an access token or a Bitbucket API token', secret: true, }, { @@ -1881,9 +1881,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/bitbucket-service-account', helpText: - 'Use a Bitbucket Cloud workspace, project, or repository access token with at least the repository scope. Enter any repository the token can read, as workspace/repository; Sim reads it once to verify the token.', + 'Use a Bitbucket Cloud workspace, project, or repository access token with at least the repository scope, or an Atlassian API token created for Bitbucket with at least repository read (app passwords no longer work). Enter any repository the token can read, as workspace/repository; Sim reads it once to verify the token.', invalidCredentialsHelp: - 'Bitbucket rejected this token. Check that it has not expired or been revoked, has the repository scope, and can read the repository you entered.', + 'Bitbucket rejected this token. Check that it has not expired or been revoked, has repository read access, and can read the repository you entered. App passwords no longer work; use an access token or an API token created for Bitbucket.', }, [GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 37763638257..641d6ec4bdf 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -642,8 +642,11 @@ const BITBUCKET_REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,254}\/[A-Za-z0-9_-][A- * Reading one repository the token can see is the request Atlassian documents for all * three kinds (`GET /2.0/repositories/{workspace}/{repository}` with the `repository` * scope): 200 with the repository, 401 for a bad token, 403 without the scope, and 404 for - * a repository that does not exist or that the token cannot reach. + * a repository that does not exist or that the token cannot reach. An Atlassian API token + * created for Bitbucket passes the same read, since Bitbucket takes it as a Bearer token + * without the account email. * https://support.atlassian.com/bitbucket-cloud/docs/using-access-tokens/ + * https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/ */ export const validateBitbucketServiceAccount = tokenCheckValidator({ serviceLabel: 'Bitbucket', From 00e95cd7fd816ee812722444922b5dfc97bd7a28 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:23:47 -0800 Subject: [PATCH 03/83] fix(credentials): ask for a reconnect when a Microsoft app certificate can no longer sign An expired or unreadable stored certificate failed before any request reached Entra, carried no status, and was reported at run time as the provider being temporarily unavailable (502). Local configuration failures now classify as invalid_credentials, and only an unreachable Entra keeps provider_unavailable. Saving also refuses a certificate that is not valid yet. --- ...crosoft-app-service-account.server.test.ts | 47 +++++++++++++++++-- .../microsoft-app-service-account.server.ts | 26 +++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 7dc2a6a40fe..adb9519a0c3 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -58,25 +58,31 @@ function derUtcTime(date: Date): Buffer { return der(0x17, Buffer.from(`${text}Z`)) } +const DAY_MS = 86_400_000 + /** * A self-signed RSA certificate (CN=sim-test-app) generated for this test run, * so no key material is committed. */ -function generateTestCertificate(): { certificate: string; privateKey: string } { +function generateTestCertificate( + validity: { notBefore: number; notAfter: number } = { + notBefore: Date.now() - DAY_MS, + notAfter: Date.now() + 365 * DAY_MS, + } +): { certificate: string; privateKey: string } { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) const sha256WithRsa = der(0x30, derOid('1.2.840.113549.1.1.11'), der(0x05)) const name = der( 0x30, der(0x31, der(0x30, derOid('2.5.4.3'), der(0x0c, Buffer.from('sim-test-app')))) ) - const now = Date.now() const tbs = der( 0x30, der(0xa0, der(0x02, Buffer.from([0x02]))), der(0x02, Buffer.from([0x01])), sha256WithRsa, name, - der(0x30, derUtcTime(new Date(now - 86_400_000)), derUtcTime(new Date(now + 365 * 86_400_000))), + der(0x30, derUtcTime(new Date(validity.notBefore)), derUtcTime(new Date(validity.notAfter))), name, publicKey.export({ type: 'spki', format: 'der' }) ) @@ -228,6 +234,18 @@ describe('saving a Microsoft app credential', () => { expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) + it('refuses a certificate that is not valid yet before contacting Entra', async () => { + fakeMicrosoft(fetch) + const future = generateTestCertificate({ + notBefore: Date.now() + 2 * DAY_MS, + notAfter: Date.now() + 365 * DAY_MS, + }) + + await expect(save({ authType: 'certificate', ...future })).rejects.toThrow(/not valid until/) + expect(fetch).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + describe('keyless', () => { const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' const reconnectOwner = { @@ -450,6 +468,29 @@ describe('getting a Microsoft app token for a run', () => { expect(tokens[1]?.form.client_secret).toBe('rotated') }) + it('asks for a reconnect once the stored certificate has expired, without contacting Entra', async () => { + fakeMicrosoft(fetch) + const expired = generateTestCertificate({ + notBefore: Date.now() - 400 * DAY_MS, + notAfter: Date.now() - DAY_MS, + }) + storeCredential({ authType: 'certificate', ...expired }, 'ciphertext-expired-certificate') + + await expect( + resolveMicrosoftAppCredentialToken('cred-expired-certificate', 'microsoft-ad') + ).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('reports an unreachable Entra as the provider being unavailable', async () => { + fetch.mockRejectedValue(new TypeError('fetch failed')) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-unreachable') + + await expect( + resolveMicrosoftAppCredentialToken('cred-unreachable', 'microsoft-ad') + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }) + }) + it('reports an Entra outage as the provider being unavailable, not a bad credential', async () => { fakeMicrosoft(fetch, { tokenError: jsonResponse({ error: 'temporarily_unavailable' }, 503) }) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-outage') diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index d98e505bfd2..2231abe2180 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -59,6 +59,8 @@ const CERTIFICATE_ASSERTION_TTL_SECONDS = 300 const MIN_RSA_MODULUS_BITS = 2048 const MAX_RSA_MODULUS_BITS = 4096 const MAX_PEM_LENGTH = 16_384 +/** A certificate issued moments ago on a machine whose clock runs ahead is not refused as "not valid yet". */ +const CERTIFICATE_CLOCK_SKEW_MS = 5 * 60 * 1000 /** Re-mint once less than this much of a token's lifetime remains. */ const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000 @@ -167,7 +169,9 @@ const storedSecretSchema = z /** A configuration problem Sim can name the fix for; its message is shown to the person saving. */ export class MicrosoftAppCredentialError extends Error { /** - * @param status The HTTP status Microsoft answered with, when Microsoft itself refused. + * @param status The HTTP status of the failed exchange: Microsoft's answer, or 502 when Entra + * could not be reached or answered without a token. Absent for a problem with the stored + * configuration itself (an expired certificate, say), which only a reconnect fixes. * @param aadsts The Entra `AADSTS` error code, when Entra reported one. */ constructor( @@ -280,6 +284,12 @@ function loadCertificateMaterial( 'This certificate has expired. Upload a new one to the app registration and save it here.' ) } + const validFrom = Date.parse(certificate.validFrom) + if (validFrom > Date.now() + CERTIFICATE_CLOCK_SKEW_MS) { + throw new MicrosoftAppCredentialError( + `This certificate is not valid until ${new Date(validFrom).toISOString()}. Use a certificate that is already valid.` + ) + } return { key, thumbprintS256: createHash('sha256').update(certificate.raw).digest('base64url'), @@ -363,7 +373,7 @@ async function requestEntraToken( }) } catch { signal?.throwIfAborted() - throw new MicrosoftAppCredentialError('Could not reach Microsoft Entra. Try again.') + throw new MicrosoftAppCredentialError('Could not reach Microsoft Entra. Try again.', 502) } const payload = (await response.json().catch(() => ({}))) as { access_token?: unknown @@ -686,11 +696,15 @@ export async function resolveMicrosoftAppCredentialToken( token = await requestEntraToken(config, resource, owner) } catch (error) { if (!(error instanceof MicrosoftAppCredentialError)) throw error - // Report in the shared service-account codes: an outage is not a bad credential. + // Report in the shared service-account codes: an outage is not a bad credential, and a + // stored certificate that can no longer sign (expired, say) is one only a reconnect fixes. throw new TokenServiceAccountValidationError( - error.status === undefined ? 'provider_unavailable' : providerFailureCode(error.status), - error.status ?? 502, - { step: 'microsoft_app_token', reason: error.aadsts ? `AADSTS${error.aadsts}` : undefined } + error.status === undefined ? 'invalid_credentials' : providerFailureCode(error.status), + error.status ?? 400, + { + step: 'microsoft_app_token', + reason: error.aadsts ? `AADSTS${error.aadsts}` : error.message, + } ) } const value: CachedToken = { accessToken: token.accessToken, instanceUrl } From 671922e9e3283937597633829bd32b72224ca7a6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:24:09 -0800 Subject: [PATCH 04/83] fix(credentials): warn that saved Infisical access tokens expire Sim stores the machine identity access token as pasted and never renews it, so it stops at its Access Token TTL (30 days by default). Tell users to set a long TTL and Max TTL, and not an Access Token Period, which needs renewal each period. --- apps/docs/content/docs/integrations/infisical.mdx | 2 ++ apps/sim/lib/credentials/token-service-accounts/descriptors.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/integrations/infisical.mdx b/apps/docs/content/docs/integrations/infisical.mdx index 92a0593db7d..b8c0cdd1374 100644 --- a/apps/docs/content/docs/integrations/infisical.mdx +++ b/apps/docs/content/docs/integrations/infisical.mdx @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Infisical](https://infisical.com/) to list, read, create, update, or delete secrets in a project environment. Connect a saved Infisical credential (a machine identity access token), then set the project ID, environment slug, and any secret path required by the selected operation. + +Sim stores the access token as pasted and does not renew it, so the credential stops working when the token's Access Token TTL runs out (30 days by default). On the identity's Universal Auth or Token Auth configuration, set a long Access Token TTL and Access Token Max TTL, and leave Access Token Period unset: a periodic token expires at the end of each period unless it is renewed. Replace the credential with a new token before the old one expires. See Infisical's [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) and [Token Auth](https://infisical.com/docs/documentation/platform/identities/token-auth) guides. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 8cbb8aa0915..e212deeedef 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -2300,7 +2300,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/infisical', helpText: - 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Universal Auth tokens expire with their TTL; a Token Auth token with a long TTL keeps runs working.', + 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim does not renew the token, so it stops working when its Access Token TTL runs out (30 days by default): set a long Access Token TTL and Max TTL on the identity, and leave Access Token Period unset, since a periodic token expires unless it is renewed.', invalidCredentialsHelp: 'Paste a machine identity access token, not a client secret or service token, and make sure the instance URL matches the region the identity lives in.', }, From f3cd901eeeb6ac17f53cb9ef407e2f78b27b8890 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:24:53 -0800 Subject: [PATCH 05/83] fix(credentials): retry keyless Microsoft token requests while a federated credential propagates Entra can refuse a new federated identity credential with AADSTS70021 for several minutes after it is added, even after a token was issued, and Microsoft asks callers to retry. A run now retries that refusal (and AADSTS700213) twice with jittered backoff before reporting it; the save-time subject-pin probe, which expects the refusal, is unchanged. --- ...crosoft-app-service-account.server.test.ts | 93 ++++++++++++++++++- .../microsoft-app-service-account.server.ts | 36 ++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index adb9519a0c3..339fc584b5f 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -13,7 +13,7 @@ import { queueTableRows, resetDbChainMock } from '@sim/testing' import { jsonResponse } from '@sim/testing/helpers/http' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { decodeProtectedHeader, jwtVerify } from 'jose' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ @@ -120,14 +120,22 @@ interface TokenRequest { */ function fakeMicrosoft( fetch: ReturnType, - options: { acceptAssertion?: (assertion: string) => boolean; tokenError?: Response } = {} + options: { + acceptAssertion?: (assertion: string) => boolean + tokenError?: Response + /** Refusals answered to the first token requests, in order, before Entra starts issuing tokens. */ + tokenErrorsFirst?: Response[] + } = {} ) { const tokenRequests: TokenRequest[] = [] + const pendingErrors = [...(options.tokenErrorsFirst ?? [])] fetch.mockImplementation(async (url: string, init: RequestInit = {}) => { if (url.startsWith('https://login.microsoftonline.com/')) { const form = Object.fromEntries(new URLSearchParams(String(init.body))) tokenRequests.push({ url, form, redirect: init.redirect }) if (options.tokenError) return options.tokenError.clone() + const pendingError = pendingErrors.shift() + if (pendingError) return pendingError if (form.client_assertion && options.acceptAssertion?.(form.client_assertion) === false) { return jsonResponse( { error: 'invalid_client', error_codes: [700213], error_description: 'AADSTS700213: …' }, @@ -491,6 +499,87 @@ describe('getting a Microsoft app token for a run', () => { ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }) }) + describe('while a new federated credential propagates', () => { + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + const refusal = (code: number) => + jsonResponse( + { + error: 'invalid_client', + error_codes: [code], + error_description: `AADSTS${code}: No matching federated identity record found for presented assertion.`, + }, + 400 + ) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function settle(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + await vi.runAllTimersAsync() + const outcome = await settled + if ('error' in outcome) throw outcome.error + return outcome.value + } + + it.each([70021, 700213])( + 'retries an AADSTS%i refusal and returns the token Entra then issues', + async (code) => { + const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(code), refusal(code)] }) + storeCredential( + { authType: 'federated', subject: SUBJECT }, + `ciphertext-propagating-${code}` + ) + + await expect( + settle(resolveMicrosoftAppCredentialToken(`cred-propagating-${code}`, 'microsoft-ad')) + ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) + expect(tokens).toHaveLength(3) + } + ) + + it('asks for a reconnect once the retries are spent', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021) }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-never-matches') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-never-matches', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(3) + }) + + it('does not retry any other refusal', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(700016) }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-unknown-app') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-unknown-app', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) + + it('does not retry the same refusal for a client secret credential', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021) }) + storeCredential( + { authType: 'client_secret', clientSecret: SECRET }, + 'ciphertext-secret-70021' + ) + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-secret-70021', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) + }) + it('reports an Entra outage as the provider being unavailable, not a bad credential', async () => { fakeMicrosoft(fetch, { tokenError: jsonResponse({ error: 'temporarily_unavailable' }, 503) }) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-outage') diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index 2231abe2180..5f0d3d4ec45 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -2,7 +2,9 @@ import { createHash, createPrivateKey, type KeyObject, X509Certificate } from 'n import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' import { eq } from 'drizzle-orm' import { SignJWT } from 'jose' import { LRUCache } from 'lru-cache' @@ -62,6 +64,11 @@ const MAX_PEM_LENGTH = 16_384 /** A certificate issued moments ago on a machine whose clock runs ahead is not refused as "not valid yet". */ const CERTIFICATE_CLOCK_SKEW_MS = 5 * 60 * 1000 +/** Extra token requests a run makes while a new federated credential propagates through Entra. */ +const FEDERATION_PROPAGATION_RETRIES = 2 +/** The refusals Entra answers while a new federated credential has not reached every node. */ +const FEDERATION_PROPAGATION_AADSTS = new Set([70021, 700213]) + /** Re-mint once less than this much of a token's lifetime remains. */ const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000 const TOKEN_CACHE_MAX = 10_000 @@ -605,6 +612,33 @@ export async function verifyAndEncryptMicrosoftAppCredential( } } +/** + * Requests a run's token, retrying a federated credential's "no matching federated identity + * record" refusal: Entra can answer it for several minutes after the federated credential is + * added, even after a token was issued, and Microsoft asks callers to retry. The save-time + * subject-pin probe never comes here, because it expects that very refusal. + * @see https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-considerations#time-for-federated-credential-changes-to-propagate + */ +async function requestRunEntraToken( + config: MicrosoftAppConfig, + resource: string, + owner: WorkloadIdentityOwner +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await requestEntraToken(config, resource, owner) + } catch (error) { + const propagating = + config.authType === 'federated' && + error instanceof MicrosoftAppCredentialError && + error.aadsts !== undefined && + FEDERATION_PROPAGATION_AADSTS.has(error.aadsts) + if (!propagating || attempt > FEDERATION_PROPAGATION_RETRIES) throw error + await sleep(backoffWithJitter(attempt, null)) + } + } +} + interface CachedToken { accessToken: string instanceUrl?: string @@ -693,7 +727,7 @@ export async function resolveMicrosoftAppCredentialToken( : { organizationId: row.organizationId ?? undefined } let token: EntraToken try { - token = await requestEntraToken(config, resource, owner) + token = await requestRunEntraToken(config, resource, owner) } catch (error) { if (!(error instanceof MicrosoftAppCredentialError)) throw error // Report in the shared service-account codes: an outage is not a bad credential, and a From 34a2d48d8386692d613e79f67426add00ee88b55 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:26:45 -0800 Subject: [PATCH 06/83] fix(infisical): keep the instance URL out of the model's reach and normalize it like a saved credential The legacy Infisical tools let the model set baseUrl, so a prompt-injected agent call could send the inline Infisical token to any public host. baseUrl is now user-only (matching Grafana and Upstash), and inline instance URLs go through the same normalizer the saved-credential validator stores, which moves from the validator module into input-validation so tools can share it. --- .../sim/lib/core/security/input-validation.ts | 41 +++++++++ .../validators/http.test.ts | 7 +- .../token-service-accounts/validators/http.ts | 41 --------- .../validators/token-checks.ts | 9 +- apps/sim/tools/infisical/create_secret.ts | 8 +- apps/sim/tools/infisical/delete_secret.ts | 8 +- apps/sim/tools/infisical/get_secret.ts | 9 +- apps/sim/tools/infisical/list_secrets.ts | 6 +- apps/sim/tools/infisical/update_secret.ts | 8 +- apps/sim/tools/infisical/utils.test.ts | 89 +++++++++++++++++++ apps/sim/tools/infisical/utils.ts | 19 ++++ 11 files changed, 184 insertions(+), 61 deletions(-) create mode 100644 apps/sim/tools/infisical/utils.test.ts create mode 100644 apps/sim/tools/infisical/utils.ts diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 6b79071b5b2..b49bdb524f7 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1408,3 +1408,44 @@ export function validateDatabaseIdentifier( } return { isValid: true, sanitized: value } } + +/** + * Drops the tabs and newlines the URL parser ignores, then refuses `https:/host`, which + * would otherwise get a second scheme and parse as a host named `https`. + */ +function refuseMalformedScheme(typed: string): string { + const stripped = typed.replace(/[\t\n\r]/g, '') + if (/^https?:(?!\/\/)/i.test(stripped)) throw new Error('malformed scheme') + return stripped +} + +/** + * A typed http(s) base URL in the form tools build request paths on: a bare host + * is taken as https, trailing slashes are dropped, and any other scheme, + * credentials, a query, or a fragment is refused, since each would retarget the + * request. + */ +export function normalizeHttpBaseUrl(raw: string | undefined): string { + const trimmed = (raw ?? '').trim() + if (!trimmed) throw new Error('missing URL') + const typed = refuseMalformedScheme(trimmed) + const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`) + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('http(s) only') + if (url.username || url.password || url.search || url.hash) { + throw new Error('credentials, query, or fragment in URL') + } + return `${url.origin}${url.pathname.replace(/\/+$/, '')}` +} + +/** + * Refuses a typed URL that carries credentials or a non-http(s) scheme, before a + * tool's own normalizer can drop the credentials silently or read the scheme as + * a host, and so verify a different identity or host than the user meant. + */ +export function refuseUnsafeTypedUrl(raw: string | undefined): string { + const typed = refuseMalformedScheme((raw ?? '').trim()) + if (/^(?!https?:\/\/)[a-z][a-z\d+.-]*:\/\//i.test(typed)) throw new Error('http(s) only') + const authority = typed.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').split(/[/?#]/)[0] + if (authority.includes('@')) throw new Error('credentials in URL') + return typed +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts index 6fad7b593e9..f5568efac4d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts @@ -8,11 +8,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { EgressRefusedError } from '@/lib/core/network/transport.server' -import { - normalizeHttpBaseUrl, - refuseUnsafeTypedUrl, - tokenCheckValidator, -} from '@/lib/credentials/token-service-accounts/validators/http' +import { normalizeHttpBaseUrl, refuseUnsafeTypedUrl } from '@/lib/core/security/input-validation' +import { tokenCheckValidator } from '@/lib/credentials/token-service-accounts/validators/http' const validate = tokenCheckValidator({ serviceLabel: 'Example', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index e31c2be069e..4763db86e32 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -99,47 +99,6 @@ export function readJsonBody(response: ProviderResponse, serviceLabel: string): }) } -/** - * Drops the tabs and newlines the URL parser ignores, then refuses `https:/host`, which - * would otherwise get a second scheme and parse as a host named `https`. - */ -function refuseMalformedScheme(typed: string): string { - const stripped = typed.replace(/[\t\n\r]/g, '') - if (/^https?:(?!\/\/)/i.test(stripped)) throw new Error('malformed scheme') - return stripped -} - -/** - * A typed http(s) base URL in the form tools build request paths on: a bare host - * is taken as https, trailing slashes are dropped, and any other scheme, - * credentials, a query, or a fragment is refused, since each would retarget the - * request. - */ -export function normalizeHttpBaseUrl(raw: string | undefined): string { - const trimmed = (raw ?? '').trim() - if (!trimmed) throw new Error('missing URL') - const typed = refuseMalformedScheme(trimmed) - const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`) - if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('http(s) only') - if (url.username || url.password || url.search || url.hash) { - throw new Error('credentials, query, or fragment in URL') - } - return `${url.origin}${url.pathname.replace(/\/+$/, '')}` -} - -/** - * Refuses a typed URL that carries credentials or a non-http(s) scheme, before a - * tool's own normalizer can drop the credentials silently or read the scheme as - * a host, and so verify a different identity or host than the user meant. - */ -export function refuseUnsafeTypedUrl(raw: string | undefined): string { - const typed = refuseMalformedScheme((raw ?? '').trim()) - if (/^(?!https?:\/\/)[a-z][a-z\d+.-]*:\/\//i.test(typed)) throw new Error('http(s) only') - const authority = typed.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').split(/[/?#]/)[0] - if (authority.includes('@')) throw new Error('credentials in URL') - return typed -} - /** A domain that must be one of a fixed set (a region or regional host), compared case-insensitively. */ export function allowedDomain(allowed: ReadonlySet) { return ({ domain }: TokenServiceAccountFields): string => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index e4f57a094e4..9845783161c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -1,5 +1,10 @@ import { toArray, toRecord } from '@sim/utils/object' -import { validateOktaDomain, validateSupabaseProjectId } from '@/lib/core/security/input-validation' +import { + normalizeHttpBaseUrl, + refuseUnsafeTypedUrl, + validateOktaDomain, + validateSupabaseProjectId, +} from '@/lib/core/security/input-validation' import { fetchProvider, TokenServiceAccountValidationError, @@ -11,9 +16,7 @@ import type { } from '@/lib/credentials/token-service-accounts/server' import { allowedDomain, - normalizeHttpBaseUrl, readJsonBody, - refuseUnsafeTypedUrl, tokenCheckValidator, } from '@/lib/credentials/token-service-accounts/validators/http' import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/lib/internal/jupyter/protocol' diff --git a/apps/sim/tools/infisical/create_secret.ts b/apps/sim/tools/infisical/create_secret.ts index a867ec18727..925711454c7 100644 --- a/apps/sim/tools/infisical/create_secret.ts +++ b/apps/sim/tools/infisical/create_secret.ts @@ -2,6 +2,7 @@ import type { InfisicalCreateSecretParams, InfisicalCreateSecretResponse, } from '@/tools/infisical/types' +import { infisicalApiUrl } from '@/tools/infisical/utils' import { INFISICAL_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -25,7 +26,7 @@ export const createSecretTool: ToolConfig< baseUrl: { type: 'string', required: false, - visibility: 'user-or-llm', + visibility: 'user-only', description: 'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.', }, @@ -82,7 +83,10 @@ export const createSecretTool: ToolConfig< request: { method: 'POST', url: (params) => - `${params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}`, + infisicalApiUrl( + params.baseUrl, + `/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}` + ), headers: (params) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/infisical/delete_secret.ts b/apps/sim/tools/infisical/delete_secret.ts index 25fe6aab47f..22448661a3c 100644 --- a/apps/sim/tools/infisical/delete_secret.ts +++ b/apps/sim/tools/infisical/delete_secret.ts @@ -2,6 +2,7 @@ import type { InfisicalDeleteSecretParams, InfisicalDeleteSecretResponse, } from '@/tools/infisical/types' +import { infisicalApiUrl } from '@/tools/infisical/utils' import { INFISICAL_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -25,7 +26,7 @@ export const deleteSecretTool: ToolConfig< baseUrl: { type: 'string', required: false, - visibility: 'user-or-llm', + visibility: 'user-only', description: 'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.', }, @@ -64,7 +65,10 @@ export const deleteSecretTool: ToolConfig< request: { method: 'DELETE', url: (params) => - `${params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}`, + infisicalApiUrl( + params.baseUrl, + `/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}` + ), headers: (params) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/infisical/get_secret.ts b/apps/sim/tools/infisical/get_secret.ts index 6815815139b..c53ee726a71 100644 --- a/apps/sim/tools/infisical/get_secret.ts +++ b/apps/sim/tools/infisical/get_secret.ts @@ -1,4 +1,5 @@ import type { InfisicalGetSecretParams, InfisicalGetSecretResponse } from '@/tools/infisical/types' +import { infisicalApiUrl } from '@/tools/infisical/utils' import { INFISICAL_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -19,7 +20,7 @@ export const getSecretTool: ToolConfig ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/infisical/list_secrets.ts b/apps/sim/tools/infisical/list_secrets.ts index 81fa10722e2..1f9daa8a713 100644 --- a/apps/sim/tools/infisical/list_secrets.ts +++ b/apps/sim/tools/infisical/list_secrets.ts @@ -2,6 +2,7 @@ import type { InfisicalListSecretsParams, InfisicalListSecretsResponse, } from '@/tools/infisical/types' +import { infisicalApiUrl } from '@/tools/infisical/utils' import { INFISICAL_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -24,7 +25,7 @@ export const listSecretsTool: ToolConfig ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/infisical/update_secret.ts b/apps/sim/tools/infisical/update_secret.ts index 4d0549d7e4b..066bd971080 100644 --- a/apps/sim/tools/infisical/update_secret.ts +++ b/apps/sim/tools/infisical/update_secret.ts @@ -2,6 +2,7 @@ import type { InfisicalUpdateSecretParams, InfisicalUpdateSecretResponse, } from '@/tools/infisical/types' +import { infisicalApiUrl } from '@/tools/infisical/utils' import { INFISICAL_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -25,7 +26,7 @@ export const updateSecretTool: ToolConfig< baseUrl: { type: 'string', required: false, - visibility: 'user-or-llm', + visibility: 'user-only', description: 'Infisical instance URL (default: "https://us.infisical.com"). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL.', }, @@ -88,7 +89,10 @@ export const updateSecretTool: ToolConfig< request: { method: 'PATCH', url: (params) => - `${params.baseUrl?.replace(/\/+$/, '') ?? 'https://us.infisical.com'}/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}`, + infisicalApiUrl( + params.baseUrl, + `/api/v4/secrets/${encodeURIComponent(params.secretName.trim())}` + ), headers: (params) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/infisical/utils.test.ts b/apps/sim/tools/infisical/utils.test.ts new file mode 100644 index 00000000000..6ccb8394b2b --- /dev/null +++ b/apps/sim/tools/infisical/utils.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { InfisicalBlock } from '@/blocks/blocks/infisical' +import { prepareToolExecution, transformBlockTool } from '@/providers/utils' +import { createSecretTool } from '@/tools/infisical/create_secret' +import { deleteSecretTool } from '@/tools/infisical/delete_secret' +import { getSecretTool } from '@/tools/infisical/get_secret' +import { listSecretsTool } from '@/tools/infisical/list_secrets' +import { updateSecretTool } from '@/tools/infisical/update_secret' +import type { ToolConfig } from '@/tools/types' + +const TOOLS: Array<{ operation: string; tool: ToolConfig }> = [ + { operation: 'list_secrets', tool: listSecretsTool }, + { operation: 'get_secret', tool: getSecretTool }, + { operation: 'create_secret', tool: createSecretTool }, + { operation: 'update_secret', tool: updateSecretTool }, + { operation: 'delete_secret', tool: deleteSecretTool }, +] + +const SECRET_PARAMS = { + apiKey: 'inline-token', + projectId: 'project-1', + environment: 'dev', + secretName: 'DB_URL', + secretValue: 'value', +} + +function requestUrl(tool: ToolConfig, params: Record): string { + return typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url +} + +describe('Infisical request host', () => { + it.each(TOOLS)( + '$operation never sends the inline token to an instance URL the model picked', + async ({ operation, tool }) => { + const providerTool = await transformBlockTool( + { + type: 'infisical', + params: { + operation, + apiKey: 'inline-token', + projectId: 'project-1', + environment: 'dev', + }, + }, + { selectedOperation: operation, getAllBlocks: () => [InfisicalBlock], getTool: () => tool } + ) + if (!providerTool) throw new Error('Infisical agent tool was not created') + + expect(providerTool.parameters.properties).not.toHaveProperty('baseUrl') + const { executionParams } = prepareToolExecution( + providerTool, + { baseUrl: 'https://attacker.example', secretName: 'DB_URL', secretValue: 'value' }, + {}, + undefined + ) + + expect(new URL(requestUrl(tool, executionParams)).origin).toBe('https://us.infisical.com') + } + ) + + it.each([ + ['', 'https://us.infisical.com'], + [' ', 'https://us.infisical.com'], + ['https://eu.infisical.com/', 'https://eu.infisical.com'], + ['eu.infisical.com', 'https://eu.infisical.com'], + [' https://infisical.example.com/infisical/ ', 'https://infisical.example.com/infisical'], + ['http://infisical.internal.example:8080', 'http://infisical.internal.example:8080'], + ])('builds on the instance URL %j the way a saved credential stores it', (baseUrl, base) => { + for (const { tool } of TOOLS) { + expect(requestUrl(tool, { ...SECRET_PARAMS, baseUrl })).toMatch( + new RegExp(`^${base.replace(/[.:/]/g, '\\$&')}/api/v[34]/secrets`) + ) + } + }) + + it.each([ + 'https://attacker.example@us.infisical.com', + 'https://us.infisical.com?redirect=https://attacker.example', + 'https://us.infisical.com#fragment', + 'https:/attacker.example', + 'ftp://us.infisical.com', + ])('refuses the instance URL %j instead of retargeting the request', (baseUrl) => { + for (const { tool } of TOOLS) { + expect(() => requestUrl(tool, { ...SECRET_PARAMS, baseUrl })).toThrow( + /Infisical instance URL/ + ) + } + }) +}) diff --git a/apps/sim/tools/infisical/utils.ts b/apps/sim/tools/infisical/utils.ts new file mode 100644 index 00000000000..ed24eca9160 --- /dev/null +++ b/apps/sim/tools/infisical/utils.ts @@ -0,0 +1,19 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { normalizeHttpBaseUrl } from '@/lib/core/security/input-validation' + +const INFISICAL_US_CLOUD_URL = 'https://us.infisical.com' + +/** + * An Infisical API URL on the configured instance. A blank instance URL means + * US Cloud; any other is normalized exactly as a saved Infisical credential's + * instance URL is, so inline and saved connections reach the same host and an + * unusable URL is refused rather than retargeting the bearer token. + */ +export function infisicalApiUrl(baseUrl: string | undefined, path: string): string { + if (!baseUrl?.trim()) return `${INFISICAL_US_CLOUD_URL}${path}` + try { + return `${normalizeHttpBaseUrl(baseUrl)}${path}` + } catch (error) { + throw new Error(`Invalid Infisical instance URL: ${getErrorMessage(error)}`) + } +} From f5e0c3682b0c689f89ea919bda541ac11f36e7e1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:27:57 -0800 Subject: [PATCH 07/83] feat(credentials): accept HubSpot service keys for the saved HubSpot credential HubSpot stopped new legacy private apps on October 26, 2026 and points direct integrations at service keys, which are Bearer tokens for the same REST APIs. The connect copy now asks for a service key or an existing private app token. The token-info route covers only private apps, so a non-transient refusal there defers to the Account Information API, and that fallback accepts a 403 only with a JSON body, so a firewall page never passes as a live token. Both checks refuse redirects. --- .../integrations/hubspot-service-account.mdx | 57 +++++++++++++------ apps/docs/content/integration-navigation.json | 2 +- .../token-service-accounts/descriptors.ts | 10 ++-- .../validators/hubspot.test.ts | 52 +++++++++++++++-- .../validators/hubspot.ts | 47 ++++++++------- 5 files changed, 118 insertions(+), 50 deletions(-) diff --git a/apps/docs/content/docs/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/integrations/hubspot-service-account.mdx index a142b0a62bb..32901cb640f 100644 --- a/apps/docs/content/docs/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/integrations/hubspot-service-account.mdx @@ -1,22 +1,43 @@ --- -title: HubSpot private app tokens -description: Set up a HubSpot private app with a scoped access token to use HubSpot in Sim workflows +title: HubSpot service keys and private app tokens +description: Connect HubSpot to Sim with a scoped service key or an existing private app access token --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Connect HubSpot with a private app access token created by a super admin. Select the CRM scopes your workflows need and keep the creating admin's account permissions in mind when managing the app. +Connect HubSpot with a service key, or with the access token of an existing legacy private app. Both are scoped Bearer credentials for your HubSpot account: select the CRM scopes your workflows need when you create one. ## Legacy app availability -HubSpot [ended legacy private-app creation](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset) on September 28, 2026 for accounts created on or after that date. Creation ends on October 26, 2026 for older accounts. Existing legacy private apps continue to work; the creation steps below apply only while the option remains available in your account. +HubSpot [ended legacy private-app creation](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset) on September 28, 2026 for accounts created on or after that date, and on October 26, 2026 for older accounts. Existing legacy private apps continue to work, so the private app steps below are for finding and managing an app you already have. -For a new Sim connection when that option is unavailable, use [HubSpot OAuth](/integrations/hubspot-setup). HubSpot recommends Service Keys for new direct integrations, but this guide does not establish that those keys are compatible with Sim’s private-app-token connection. +For a new connection, create a [service key](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys), which HubSpot recommends for new direct integrations, or use [HubSpot OAuth](/integrations/hubspot-setup). Sim calls HubSpot's REST APIs with a service key as a Bearer token, exactly as it does with a private app token. -## Prerequisites +## Create a service key [#creating-a-service-key] + +Super admins and users with the **Developer tools access** permission can create service keys. + + + + In HubSpot, navigate to **Development**, then **Keys** → **Service keys** + + + Click **Create service key** and give it a name (e.g. `sim-hubspot`) + + + Click **Add new scope** and select the scopes your workflows need (see [Choose scopes](#2-choose-scopes)), then click **Create** and confirm + + + Open the key, click **Show**, then **Copy**. Continue at [Add the key to Sim](#adding-the-private-app-token-to-sim) + + + +A service key works only for REST API calls. If you turn on automatic rotation, HubSpot replaces the key every 3, 6, or 12 months and emails admins before the old key expires; paste the new key into the credential in Sim each time. + +## Prerequisites for a private app [#prerequisites] You need a HubSpot **super admin** to create the private app. Private apps can only be created and managed by super admins in the portal. @@ -47,7 +68,7 @@ The token is tied to the super admin who created the private app. If that user i ### 2. Choose scopes [#2-choose-scopes] -Private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is: +Service keys and private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is: **CRM objects (read/write as needed):** ``` @@ -76,7 +97,7 @@ crm.lists.read crm.lists.write ``` -Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab, commit the change in HubSpot, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim. +Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the key's or app's scopes in HubSpot, save the change, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim. Sim's OAuth flow also requests an `oauth` scope — that one is OAuth-app-only and doesn't exist for private apps. You don't need it and can't grant it. @@ -88,37 +109,37 @@ The token looks like `pat-na1-…` (North America) or `pat-eu1-…` (EU data res The access token is bearer credentials for your entire portal, limited only by its scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. -## Add the private app token to Sim [#adding-the-private-app-token-to-sim] +## Add the key to Sim [#adding-the-private-app-token-to-sim] Open **Integrations** from your workspace sidebar - Search for "HubSpot" and open it, then click **Add to Sim** and choose **Add private app token** + Search for "HubSpot" and open it, then click **Add to Sim** and choose **Add service key** - Paste the **Private app access token**, and optionally set a display name and description + Paste the service key or private app token into **Service key or private app token**, and optionally set a display name and description - Click **Add private app token**. Sim checks HubSpot’s token-info endpoint and, for ambiguous responses, its Account Information API — if it fails, you'll see a specific error explaining what went wrong. + Click **Add service key**. Sim checks HubSpot’s token-info endpoint and, when that endpoint doesn't recognize the key, its Account Information API — if it fails, you'll see a specific error explaining what went wrong. ## Use the credential in workflows [#using-the-credential-in-workflows] -Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, select the saved HubSpot private app token. Configure the operation and its required fields. Private app tokens also work with the polling trigger. If the creating admin leaves or loses permissions, follow the account-management guidance above. +Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, select the saved HubSpot credential. Configure the operation and its required fields. Service keys and private app tokens also work with the polling trigger. If the creating admin leaves or loses permissions, follow the account-management guidance above. -The block calls `api.hubapi.com` using the private app token — the same API requests used by the OAuth connection. Availability depends on the app’s scopes and account access. +The block calls `api.hubapi.com` using the key — the same API requests used by the OAuth connection. Availability depends on the key's scopes and account access. ## Rotate the token [#rotating-the-token] -Private app tokens don't auto-expire, but HubSpot recommends rotating them every six months and emails super admins once a token hasn't rotated in about 180 days. From the app's Auth tab you can: +Service keys and private app tokens don't expire unless rotated, but HubSpot recommends rotating them every six months. From the service key's page, or the private app's Auth tab, you can: -- **Rotate and expire now** — the old token dies immediately -- **Rotate and expire later** — the old token keeps working for 7 days while you migrate +- **Rotate and expire now**: the old key stops working immediately +- **Rotate and expire later**: the old key keeps working for 7 days while you migrate -Prefer the 7-day option: rotate in HubSpot, paste the new token into the credential in Sim, confirm your workflows run, and let the old token lapse. +Prefer the 7-day option: rotate in HubSpot, paste the new key into the credential in Sim, confirm your workflows run, and let the old key lapse. ## Handle permission failures diff --git a/apps/docs/content/integration-navigation.json b/apps/docs/content/integration-navigation.json index 17a8067e6a1..1f757240e19 100644 --- a/apps/docs/content/integration-navigation.json +++ b/apps/docs/content/integration-navigation.json @@ -47,7 +47,7 @@ }, "hubspot-service-account": { "integration": "hubspot", - "title": "Private app tokens" + "title": "Service keys and private app tokens" }, "hubspot-setup": { "integration": "hubspot", diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index e212deeedef..27812327331 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -327,21 +327,21 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'HubSpot', - tokenNoun: 'private app access token', - connectNoun: 'private app token', + tokenNoun: 'service key or private app access token', + connectNoun: 'service key', fields: [ { id: 'apiToken', - label: 'Private app access token', + label: 'Service key or private app token', placeholder: 'pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', secret: true, hintPattern: /^pat-/i, - hintMessage: 'HubSpot private app tokens usually start with pat-.', + hintMessage: 'HubSpot service keys and private app tokens usually start with pat-.', }, ], docsUrl: 'https://docs.sim.ai/integrations/hubspot-service-account', helpText: - 'Tokens are tied to the super admin who created the private app; if that user is removed from the portal, some calls may start failing.', + 'Create a service key in HubSpot under Development > Keys > Service keys; new legacy private apps can no longer be created, but existing private app tokens keep working. If the key rotates automatically, paste the new key here after each rotation.', }, [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 53183f36ce8..0644438fd96 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -40,6 +40,7 @@ function expectFallbackCall(): void { describe('validateHubspotServiceAccount', () => { beforeEach(() => { + mockFetch.mockReset() vi.stubGlobal('fetch', mockFetch) }) @@ -82,22 +83,63 @@ describe('validateHubspotServiceAccount', () => { const result = await validateHubspotServiceAccount(FIELDS) expect(result).toEqual({ - displayName: 'HubSpot private app', + displayName: 'HubSpot', principal: null, auditMetadata: {}, }) }) - it('throws invalid_credentials on primary 401 without calling the fallback', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) - ) + it('verifies through account-info a key the token-info route answers with a 401', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ portalId: 456, uiDomain: 'app.hubspot.com' })) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toMatchObject({ + displayName: 'HubSpot portal 456', + principal: { kind: 'tenant', id: '456' }, + }) + expectFallbackCall() + }) + + it('throws invalid_credentials when both routes answer 401', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', code: 'invalid_credentials', status: 401, }) + }) + + it('does not take a firewall 403 page from account-info as a live token', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + new Response('Access denied', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('reports an outage without the fallback when token-info is unavailable', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ status: 'error' }, 503)) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) expect(mockFetch).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index c22452e5b55..9198c213b42 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -2,6 +2,7 @@ import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, + providerFailureCode, readProviderErrorSnippet, TokenServiceAccountValidationError, throwForProviderResponse, @@ -26,26 +27,29 @@ interface HubspotAccountInfo { } /** - * Fallback verification via the Account Information API. Live probing shows - * the documented access-token-info route returns a bare 404 for invalid - * tokens, which is ambiguous (invalid token vs. missing route), while this - * regular API route answers with a proper JSON 401 for bad tokens. A 200 - * proves the token is live; 401 means it was rejected; 403 means the token - * authenticated but the app lacks account-info access — still a live token. + * Fallback verification via the Account Information API, a regular REST route + * that any HubSpot Bearer credential (service key or private app token) can + * call. It answers a bad token with a JSON 401. A 200 proves the token is + * live; a JSON 403 means the token authenticated but lacks account-info + * access, still a live token. An HTML 403 is a firewall page, not HubSpot. */ async function verifyViaAccountInfo( accessToken: string ): Promise { const res = await fetchProvider( ACCOUNT_INFO_URL, - { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + redirect: 'error', + }, 'account_info' ) - if (res.status === 403) { - // The token is live but the app cannot read account info, so neither the - // portal nor the creating user is knowable on this path. + if (res.status === 403 && (res.headers.get('content-type') ?? '').includes('application/json')) { + // The token is live but cannot read account info, so neither the portal + // nor the creating user is knowable on this path. + await readProviderErrorSnippet(res) return { - displayName: 'HubSpot private app', + displayName: 'HubSpot', principal: null, auditMetadata: {}, } @@ -55,7 +59,7 @@ async function verifyViaAccountInfo( const info = await parseProviderJson(res, 'account_info') const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined return { - displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app', + displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot', // This route never reports the private app's creating user, so the portal // is the finest identity available here. principal: hubId ? tenantPrincipal(hubId) : null, @@ -64,16 +68,17 @@ async function verifyViaAccountInfo( } /** - * Validates a HubSpot private app access token. + * Validates a HubSpot service key or private app access token. * * Primary path: the documented access-token-info endpoint, sending both the * JSON body (`tokenKey`) and the `Authorization: Bearer` header — the header * is optional for NA (`pat-na1`) tokens but required for EU (`pat-eu1`) * tokens, so one code path covers both regions. * - * Live probing shows that endpoint returns a bare 404 (HTML) when the token - * is not recognized, so a 404 falls back to the Account Information API, - * which distinguishes a rejected token (JSON 401) from a live one (200/403). + * That endpoint is documented only for private app tokens, and live probing + * shows it returns a bare 404 (HTML) for a token it does not recognize, so + * any non-transient 4xx falls back to the Account Information API, which + * distinguishes a rejected token (JSON 401) from a live one (200/403). * * The display name is always `HubSpot portal {hubId}` — the account-info * `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), not a @@ -93,14 +98,14 @@ export async function validateHubspotServiceAccount( Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ tokenKey: accessToken }), + redirect: 'error', }, 'access_token_info' ) - if (res.status === 404 || res.status === 400 || res.status === 403) { - // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s - // malformed ones); a 403 here is unexpected since the route needs no - // scopes, so it also defers to the fallback rather than blaming the - // token. Resolve via a regular API route with JSON errors. + if (!res.ok && providerFailureCode(res.status) === 'invalid_credentials') { + // Not decisive: this route 404s tokens it does not recognize and covers + // only private apps, so a service key may be refused here yet work on + // every REST route. Resolve via a regular API route with JSON errors. await readProviderErrorSnippet(res) return verifyViaAccountInfo(accessToken) } From 94da15553971f85c1d53c554878388857658a1bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:28:18 -0800 Subject: [PATCH 08/83] feat(credentials): accept Microsoft app credentials for Intune Every Intune operation the block offers (managed devices, detected apps, compliance policies and their device statuses, device configurations, and the sync, restart, remote lock, and retire actions) documents application permissions in Microsoft Graph v1.0, so the Intune picker now lists Microsoft app credentials and the resolver mints a Graph token for them. OAuth Intune connections are unchanged. The setup docs name the application permissions each operation needs. --- apps/docs/content/docs/integrations/intune.mdx | 2 ++ .../integrations/microsoft-app-credentials.mdx | 7 ++++--- .../credentials/application/provider-catalog.ts | 2 +- ...microsoft-app-service-account.server.test.ts | 14 ++++++++++++++ .../microsoft-app-service-account.server.ts | 17 +++++++++++++---- apps/sim/lib/oauth/oauth.ts | 1 + apps/sim/lib/oauth/token-resolution.test.ts | 2 +- 7 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/integrations/intune.mdx b/apps/docs/content/docs/integrations/intune.mdx index d32b009ae32..0a45f6492f1 100644 --- a/apps/docs/content/docs/integrations/intune.mdx +++ b/apps/docs/content/docs/integrations/intune.mdx @@ -14,6 +14,8 @@ Use [Microsoft Intune](https://www.microsoft.com/en-us/security/business/microso Connect a Microsoft work or school account with an appropriate Intune role. The tenant needs an active Intune license and administrator consent for the delegated permissions below. Self-hosted deployments require `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, and the matching `/api/auth/oauth2/callback/microsoft-intune` callback. +To run as an app registration instead of a signed-in administrator, use a [Microsoft app credential](/integrations/microsoft-app-credentials) with the matching application permissions; every operation supports them. + List operations return one page. Pass `nextLink` to the same operation and resource to continue. Policy and configuration outputs contain common metadata and exclude platform-specific settings. An accepted device action confirms that Intune received the request; it does not confirm completion on the device. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx index 4e91652bc1d..40895257e5d 100644 --- a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx +++ b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx @@ -1,12 +1,12 @@ --- title: Microsoft app credentials -description: Run Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim +description: Run Microsoft Entra ID, SharePoint, Intune, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. +A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Intune**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in person and don't accept an app credential. The Azure AD **Reset password** operation also needs a signed-in admin, because Microsoft Graph doesn't support application permissions for it. @@ -20,7 +20,7 @@ Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in | **Client secret** | The tenant ID, client ID, and client secret | Quick setup. Secrets expire, so plan to rotate them. | | **Certificate** | The tenant ID, client ID, the certificate, and its private key | When your organization requires certificate credentials. Sim signs each token request with the key. | -Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD and SharePoint blocks get a Microsoft Graph token; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. +Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD, SharePoint, and Intune blocks get a Microsoft Graph token; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. ## Register the app and grant permissions @@ -32,6 +32,7 @@ Every option resolves on Sim's servers at run time, and tokens never appear in b Under **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions**, add what your workflows need, then select **Grant admin consent**. Application permissions always need admin consent. For example: - Azure AD: `User.Read.All` or `User.ReadWrite.All`, `Group.ReadWrite.All`, `GroupMember.ReadWrite.All`, `AuditLog.Read.All` for sign-in and audit logs, `Policy.Read.All` for conditional access, `UserAuthenticationMethod.Read.All` for authentication methods, `User.RevokeSessions.All` to revoke sessions. Setting a password needs `User-PasswordProfile.ReadWrite.All` **and** the app assigned at least the User Administrator role. - SharePoint: `Sites.Read.All` or `Sites.ReadWrite.All`. Creating and editing pages needs `Sites.ReadWrite.All`. `Sites.Selected` limits the app to sites you grant it, but searching for sites doesn't work with it, so enter site IDs directly. + - Intune: `DeviceManagementManagedDevices.Read.All` for devices and detected apps, `DeviceManagementConfiguration.Read.All` for compliance policies, their device statuses, and device configurations, and `DeviceManagementManagedDevices.PrivilegedOperations.All` to sync, restart, remotely lock, or retire a device. The tenant needs an active Intune license. Dataverse and Dynamics 365 don't use API permissions. Instead, in the [Power Platform admin center](https://admin.powerplatform.microsoft.com), open the environment → **Settings** → **Users + permissions** → **Application users** → **New app user**, add this app, pick a business unit, and assign a security role. Then enter the environment URL (like `https://contoso.crm.dynamics.com`) on the credential. diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index a688811e19f..4e00fb90985 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -235,7 +235,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript return { name: 'Microsoft app credential', description: - 'Use Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret or a certificate.', + 'Use Microsoft Entra ID, SharePoint, Intune, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret or a certificate.', docsUrl: 'https://docs.sim.ai/integrations/microsoft-app-credentials', fields: [ { diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 339fc584b5f..16bc644b243 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -451,6 +451,20 @@ describe('getting a Microsoft app token for a run', () => { expect(tokens.map((request) => request.form.scope)).toEqual([`${ENVIRONMENT}/.default`]) }) + it('mints a Microsoft Graph token for Intune, shared with the other Graph services', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-intune') + await expect( + resolveMicrosoftAppCredentialToken('cred-intune', 'microsoft-intune') + ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-intune') + await resolveMicrosoftAppCredentialToken('cred-intune', 'microsoft-ad') + + expect(tokens.map((request) => request.form.scope)).toEqual([ + 'https://graph.microsoft.com/.default', + ]) + }) + it('refuses Dataverse for a credential saved without an environment', async () => { fakeMicrosoft(fetch) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-graph-only') diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index 5f0d3d4ec45..ab939420788 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -35,7 +35,7 @@ import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * Saved Microsoft Entra app-only credentials (OAuth 2.0 client credentials). * * One app registration serves every Microsoft service that runs without a - * signed-in user: Microsoft Graph for Entra ID and SharePoint, and the + * signed-in user: Microsoft Graph for Entra ID, SharePoint, and Intune, and the * credential's own Dataverse environment for Dataverse and Dynamics 365. The * app's application permissions and the environment's application user decide * what it can do; Sim stores no permission list of its own. @@ -79,7 +79,11 @@ const TENANT_DOMAIN_PATTERN = /^(?=.{4,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/ /** The Microsoft services that may use this credential, and the resource each one calls. */ -type MicrosoftAppService = 'microsoft-ad' | 'sharepoint' | 'microsoft-dataverse' +type MicrosoftAppService = + | 'microsoft-ad' + | 'sharepoint' + | 'microsoft-intune' + | 'microsoft-dataverse' const tenantSchema = z .string() @@ -657,14 +661,19 @@ export interface MicrosoftAppCredentialToken { } function isMicrosoftAppService(value: string | undefined): value is MicrosoftAppService { - return value === 'microsoft-ad' || value === 'sharepoint' || value === 'microsoft-dataverse' + return ( + value === 'microsoft-ad' || + value === 'sharepoint' || + value === 'microsoft-intune' || + value === 'microsoft-dataverse' + ) } /** * Resolves an executor-authorized Microsoft app credential (the executor * already checked the caller may use it) to a token for the service the tool * belongs to. The resource is decided by the tool's service, never by caller - * params: Graph for Entra ID and SharePoint, the stored environment for + * params: Graph for Entra ID, SharePoint, and Intune, the stored environment for * Dataverse. Any other service is refused, so a Microsoft block that needs a * signed-in user can never receive an app-only token. */ diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2ffd1bd701c..9efda6d3242 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -671,6 +671,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'microsoft-intune', icon: IntuneIcon, baseProviderIcon: MicrosoftIcon, + serviceAccountProviderId: 'microsoft-app-service-account', scopes: [ 'openid', 'profile', diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 30f9189c6d7..edd428d61a5 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -694,7 +694,7 @@ describe('Microsoft app credential binding', () => { } ) - it.each(['microsoft-ad', 'sharepoint', 'microsoft-dataverse'] as const)( + it.each(['microsoft-ad', 'sharepoint', 'microsoft-dataverse', 'microsoft-intune'] as const)( 'mints an app-only token for %s and tells the resolver which service it is for', async (provider) => { toolFor(provider) From 33076a295a23a0f6507a8dad5c59928b15415da8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:29:09 -0800 Subject: [PATCH 09/83] fix(credentials): refuse rotating Slack tokens for custom bot credentials A Slack app with token rotation on issues xoxe.-prefixed tokens that expire in 12 hours and can only be refreshed with the app's client ID and secret, which Sim never holds. Such a token passed auth.test at connect and failed every run after. Refuse xoxe tokens up front with a message that asks for the xoxb- token of an app with rotation off. --- .../lib/credentials/service-account-secret.test.ts | 14 ++++++++++++++ apps/sim/lib/credentials/service-account-secret.ts | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 4cdd2f13815..1bca34331fb 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -87,6 +87,20 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it.each([ + ['an expiring bot token', 'xoxe.xoxb-1-abc'], + ['a refresh token', 'xoxe-1-abc'], + ])('refuses %s from token rotation, which Sim cannot refresh', async (_case, botToken) => { + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T1', userId: 'U_BOT', teamName: 'Acme' }) + + await expect( + verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { + signingSecret: 'sec', + botToken, + }) + ).rejects.toThrow('token rotation') + }) + it('verifies an Atlassian token and encrypts the blob', async () => { mockValidateAtlassian.mockResolvedValue({ accountId: 'acc-1', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index dcd62f7ea20..406b0a98653 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -195,6 +195,14 @@ async function buildSlackCustomBotSecret( 'signingSecret and botToken are required for a custom Slack bot credential' ) } + // Rotation-enabled apps issue `xoxe.`-prefixed access tokens that expire in 12 hours and + // `xoxe-` refresh tokens; refreshing needs the app's client ID and secret, which Sim + // never holds, so such a token would pass auth.test today and fail every run tomorrow. + if (/^\s*xoxe[.-]/.test(botToken)) { + throw new ServiceAccountSecretError( + 'This Slack app has token rotation turned on, so its tokens expire within 12 hours and Sim cannot refresh them. Use the Bot User OAuth Token (xoxb-) of a Slack app with token rotation off.' + ) + } let teamId: string let botUserId: string | undefined let teamName: string | undefined From cebf93a5e9e219e27d9f9f29781ca9f396bc0613 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:29:39 -0800 Subject: [PATCH 10/83] docs(credentials): correct the Devin key check comment to match Devin's docs Devin documents 401 for an invalid key and one 403 for both a key outside the organization and a key without ViewOrgSessions. Behavior is unchanged: nothing documented separates the two 403s, so a 403 still rejects. --- .../token-service-accounts/validators/token-checks.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 641d6ec4bdf..846638338e7 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -397,9 +397,11 @@ export const validateDaytonaServiceAccount = tokenCheckValidator({ /** * Listing one session of the organization is the tools' own read on their host and auth: 200 - * proves the key and its access to that organization. Devin answers an unknown key, or a key - * from another organization, with a problem+json 403, so a 403 rejects. The id must survive - * URL encoding unchanged, so it can never steer the tools' unencoded path. + * proves the key and its access to that organization (it needs `ViewOrgSessions`). Devin + * answers an invalid key with 401, and both a key outside the organization and one without the + * permission with the same problem+json 403; nothing documented tells those two apart, so a + * 403 rejects. The id must survive URL encoding unchanged, so it can never steer the tools' + * unencoded path. */ export const validateDevinServiceAccount = tokenCheckValidator({ serviceLabel: 'Devin', From 424d8e399ea82f45ecd1250e12a5880512c9ab2e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:31:05 -0800 Subject: [PATCH 11/83] fix(credentials): refuse redirects in every per-vendor saved-key check The shared token-check engine already refuses redirects, but the per-vendor validators followed them. Fetch keeps custom headers such as x-api-key and X-Shopify-Access-Token on a cross-origin redirect, so a redirected check would hand the key to another host. Every per-vendor check now refuses a redirect, which surfaces as the provider being unavailable. --- .../validators/airtable.ts | 1 + .../validators/asana.ts | 1 + .../validators/attio.test.ts | 1 + .../validators/attio.ts | 1 + .../validators/calcom.ts | 1 + .../validators/claude-platform.ts | 1 + .../validators/clickup.ts | 1 + .../validators/linear.ts | 1 + .../validators/monday.test.ts | 1 + .../validators/monday.ts | 1 + .../validators/notion.ts | 1 + .../validators/pipedrive.ts | 1 + .../validators/redirects.test.ts | 45 +++++++++++++++++++ .../validators/shopify.test.ts | 1 + .../validators/shopify.ts | 1 + .../validators/trello.ts | 2 +- .../validators/wealthbox.ts | 7 ++- .../validators/webflow.ts | 1 + 18 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index c70ed3c4b1b..465e93d146e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -31,6 +31,7 @@ export async function validateAirtableServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, + redirect: 'error', }, 'whoami' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index e35f0adff37..7e5fbe3b352 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -36,6 +36,7 @@ export async function validateAsanaServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, + redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index e5d4b7fc24c..af8d963a7eb 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -42,6 +42,7 @@ describe('validateAttioServiceAccount', () => { Authorization: 'Bearer attio-token', Accept: 'application/json', }, + redirect: 'error', signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index c0b7ccc1d0d..7470a7983f5 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -37,6 +37,7 @@ export async function validateAttioServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, + redirect: 'error', }, 'self' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index bd8cc59ab07..6549da6f905 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -39,6 +39,7 @@ export async function validateCalcomServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, + redirect: 'error', }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts index 566834f4fd2..4a13ae1ac94 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts @@ -29,6 +29,7 @@ export async function validateClaudePlatformServiceAccount( 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': MANAGED_AGENTS_BETA, }, + redirect: 'error', }, 'agents_list' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts index 6facd5d4b28..f7371a55b20 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -39,6 +39,7 @@ export async function validateClickupServiceAccount( Authorization: clickupAuthorizationHeader(fields.apiToken), 'Content-Type': 'application/json', }, + redirect: 'error', }, 'user' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 46c98aef7b0..87313015e11 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -81,6 +81,7 @@ export async function validateLinearServiceAccount( 'Content-Type': 'application/json', }, body: JSON.stringify({ query: VIEWER_QUERY }), + redirect: 'error', }, 'viewer' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index 762b925f07f..ee2ceb9935f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -32,6 +32,7 @@ describe('validateMondayServiceAccount', () => { body: JSON.stringify({ query: 'query { me { id name email } account { id name slug } }', }), + redirect: 'error', signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 75fa3fdbe9e..9431d5634e4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -80,6 +80,7 @@ export async function validateMondayServiceAccount( method: 'POST', headers: mondayHeaders(fields.apiToken), body: JSON.stringify({ query: VALIDATION_QUERY }), + redirect: 'error', }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index e2b75762e48..a889fa49af8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -43,6 +43,7 @@ export async function validateNotionServiceAccount( 'Notion-Version': NOTION_VERSION, Accept: 'application/json', }, + redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts index 66da8628dfa..4af7f4204e9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts @@ -42,6 +42,7 @@ export async function validatePipedriveServiceAccount( 'x-api-token': fields.apiToken, Accept: 'application/json', }, + redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts new file mode 100644 index 00000000000..5e97261964a --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts @@ -0,0 +1,45 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it, vi } from 'vitest' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { validateClaudePlatformServiceAccount } from '@/lib/credentials/token-service-accounts/validators/claude-platform' +import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' + +/** + * Fetch keeps custom headers such as `x-api-key` on a cross-origin redirect (it + * strips only `Authorization`), so a check that followed one would hand the key to + * whatever host the redirect names. This fetch answers the way a redirect would: + * refused when the check asks for that, otherwise served by the redirect target, + * which here accepts anything. + */ +function redirectingFetch(targetAnswer: unknown) { + return vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.redirect === 'error') throw new TypeError('fetch failed: unexpected redirect') + return jsonResponse(targetAnswer) + }) +} + +describe('saved key checks that send the key in a custom header', () => { + it.each< + [ + string, + (fields: TokenServiceAccountFields) => Promise, + TokenServiceAccountFields, + unknown, + ] + >([ + ['Claude Platform', validateClaudePlatformServiceAccount, { apiToken: 'sk-ant-key' }, {}], + [ + 'Shopify', + validateShopifyServiceAccount, + { apiToken: 'shpat_key', domain: 'acme.myshopify.com' }, + { data: { shop: { name: 'Elsewhere', myshopifyDomain: 'acme.myshopify.com' } } }, + ], + ])('%s refuses to follow a redirect with the key', async (_vendor, validate, fields, answer) => { + vi.stubGlobal('fetch', redirectingFetch(answer)) + + await expect(validate(fields)).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 86cfae024e1..a407d957317 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -37,6 +37,7 @@ describe('validateShopifyServiceAccount', () => { 'X-Shopify-Access-Token': 'shpat_abc', }, body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }), + redirect: 'error', signal: expect.any(AbortSignal), } ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 54ff330897a..a19f6735f9f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -95,6 +95,7 @@ export async function validateShopifyServiceAccount( 'X-Shopify-Access-Token': fields.apiToken, }, body: JSON.stringify({ query: SHOP_QUERY }), + redirect: 'error', }, 'shop_query', { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 500a8c6b537..8caa24aaf32 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -48,7 +48,7 @@ export async function validateTrelloServiceAccount( const res = await fetchProvider( url.toString(), - { headers: { Accept: 'application/json' } }, + { headers: { Accept: 'application/json' }, redirect: 'error' }, 'members_me' ) if (res.status === 401) { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index ac97ca76474..320bcae27d3 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -33,7 +33,7 @@ async function probeAccessTokenHeader(apiToken: string): Promise { try { const res = await fetchProvider( ME_URL, - { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' } }, + { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' }, redirect: 'error' }, 'me-access-token-probe' ) return res.ok @@ -61,7 +61,10 @@ export async function validateWealthboxServiceAccount( const res = await fetchProvider( ME_URL, - { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' } }, + { + headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, + redirect: 'error', + }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 4da3aeba131..6ba373ea9b9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -36,6 +36,7 @@ export async function validateWebflowServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, + redirect: 'error', }, 'list_sites' ) From 5b268da013025179c2e72c25929c446eed4df773 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:31:53 -0800 Subject: [PATCH 12/83] fix(credentials): treat a Webflow token that reaches no site as the user's error GET /v2/sites lists every site the token can access, so an empty list is a token the user must replace, not a retryable outage. A token that reaches several sites is no longer pinned to whichever site the list returns first. --- .../token-service-accounts/descriptors.ts | 2 + .../validators/webflow.test.ts | 38 +++++++++++++++++++ .../validators/webflow.ts | 26 ++++++++++--- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 27812327331..d1bcdc79720 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -526,6 +526,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account', helpText: 'Site tokens expire after 365 days without API activity, and each token grants access to a single site.', + invalidCredentialsHelp: + 'Webflow rejected this token, or it reaches no site. Generate a site API token in the site settings with at least the sites:read scope.', }, [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts new file mode 100644 index 00000000000..8c8ec4f3f50 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -0,0 +1,38 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it, vi } from 'vitest' +import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow' + +function sitesResponse(sites: unknown[]) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ sites }))) +} + +describe('validateWebflowServiceAccount', () => { + it('names the one site a site token reaches as its tenant', async () => { + sitesResponse([{ id: 'site-1', displayName: 'Marketing' }]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).resolves.toMatchObject({ + displayName: 'Marketing', + principal: { kind: 'tenant', id: 'site-1' }, + }) + }) + + it('rejects a token that reaches no site as the wrong token, not an outage', async () => { + sitesResponse([]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('does not pin a token that reaches several sites to whichever comes first', async () => { + sitesResponse([ + { id: 'site-1', displayName: 'Marketing' }, + { id: 'site-2', displayName: 'Docs' }, + ]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).resolves.toMatchObject({ + displayName: 'Webflow', + principal: null, + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 6ba373ea9b9..1a8b5623834 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -22,9 +22,10 @@ interface WebflowSite { * site and doubles as the whoami-equivalent (site tokens cannot call the * authorization endpoints). Requires the `sites:read` scope — a token missing * it surfaces as 401/403, which maps to `invalid_credentials`; any other - * non-2xx means Webflow is unavailable. A 200 with an empty `sites` array is - * treated as `provider_unavailable` since a site token should always list its - * one site. + * non-2xx means Webflow is unavailable. The route lists every site the token + * can access, so an empty list is a token that reaches no site, which the + * user must replace. Only a token that reaches exactly one site is named + * after it; one that reaches several has no single site to stand for it. */ export async function validateWebflowServiceAccount( fields: TokenServiceAccountFields @@ -43,11 +44,26 @@ export async function validateWebflowServiceAccount( await throwForProviderResponse(sitesRes, 'list_sites') const payload = await parseProviderJson<{ sites?: WebflowSite[] }>(sitesRes, 'list_sites') - const site = payload.sites?.[0] + if (!Array.isArray(payload.sites)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'list_sites', + reason: 'missing sites array in response', + }) + } + if (payload.sites.length === 0) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'list_sites', + reason: 'token reaches no site', + }) + } + if (payload.sites.length > 1) { + return { displayName: 'Webflow', principal: null, auditMetadata: {} } + } + const [site] = payload.sites if (!site?.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'list_sites', - reason: 'missing or empty sites array in response', + reason: 'site without an id in response', }) } From 3d26ae64ca8913023e99e00651dc4c76f7a11f53 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:32:01 -0800 Subject: [PATCH 13/83] feat(credentials): pick and connect Google service accounts for Vertex AI The Vertex AI runtime already resolves Google service account credentials, keyless or JSON key, but the Google Cloud Account picker on model blocks only listed OAuth accounts, so a saved service account could be used only by pasting its ID in advanced mode. Vertex AI now names google-service-account as its service-account provider, and its picker lists both kinds with a connect action for each, so the keyless option is offered for Vertex. Existing OAuth selections and the advanced credential ID field are unchanged. --- .../integrations/google-service-account.mdx | 2 +- .../api/auth/oauth/credentials/route.test.ts | 60 +++++++++++++++++++ apps/sim/blocks/utils.ts | 1 + apps/sim/lib/oauth/oauth.ts | 4 +- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/integrations/google-service-account.mdx b/apps/docs/content/docs/integrations/google-service-account.mdx index 5a2e8d86514..4fcb62b7ba2 100644 --- a/apps/docs/content/docs/integrations/google-service-account.mdx +++ b/apps/docs/content/docs/integrations/google-service-account.mdx @@ -180,7 +180,7 @@ A credential can serve multiple supported Google services when their APIs and sc A Google service account credential can also be **keyless**: Sim stores no key. Instead, your Google Cloud project trusts Sim through [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers). On each run, Sim signs a short-lived token for this credential's own subject, exchanges it at Google's Security Token Service for a federated token, and uses that to impersonate your service account. The service account's IAM roles decide what workflows can do, exactly as with a JSON key. -Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** wherever a Google service account credential is accepted. They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those. +Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** in every block that runs a Vertex model (pick the credential in its **Google Cloud Account** field). They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those. diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 4f4329eda28..bdf974357ee 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -166,6 +166,66 @@ describe('OAuth Credentials API Route', () => { ) }) + describe('Vertex AI account listing', () => { + const workspaceId = '3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + + beforeEach(() => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockReset().mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + }) + + it('lists the workspace’s Google service accounts beside its Vertex AI OAuth accounts', async () => { + const updatedAt = new Date('2026-01-01T00:00:00Z') + dbChainMockFns.where + .mockResolvedValueOnce([ + { + id: 'vertex-oauth-1', + displayName: 'Vertex account', + providerId: 'vertex-ai', + scope: getCanonicalScopesForProvider('vertex-ai').join(' '), + updatedAt, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'google-sa-1', + displayName: 'Keyless Vertex', + providerId: 'google-service-account', + updatedAt, + }, + ]) + + const response = await GET( + createMockRequestWithQuery('GET', `?provider=vertex-ai&workspaceId=${workspaceId}`) + ) + + expect(response.status).toBe(200) + const { credentials } = await response.json() + expect( + credentials.map((credential: { id: string; type: string; provider: string }) => ({ + id: credential.id, + type: credential.type, + provider: credential.provider, + })) + ).toEqual([ + { id: 'vertex-oauth-1', type: 'oauth', provider: 'vertex-ai' }, + { id: 'google-sa-1', type: 'service_account', provider: 'google-service-account' }, + ]) + }) + }) + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ describe('integrations.manage', () => { const INTEGRATIONS_WITHHELD = { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a929c3ceb30..9cc795c3ea5 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -521,6 +521,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { type: 'oauth-input', serviceId: 'vertex-ai', canonicalParamId: 'vertexCredential', + credentialKind: 'any', mode: 'basic', requiredScopes: getScopesForService('vertex-ai'), placeholder: 'Select Google Cloud account', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 9efda6d3242..d0c6212239a 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -591,7 +591,8 @@ export const OAUTH_PROVIDERS: Record = { }, 'vertex-ai': { name: 'Vertex AI', - description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.', + description: + 'Access Google Cloud Vertex AI for Gemini models with OAuth or a Google service account.', providerId: 'vertex-ai', icon: VertexIcon, baseProviderIcon: VertexIcon, @@ -600,6 +601,7 @@ export const OAUTH_PROVIDERS: Record = { 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/cloud-platform', ], + serviceAccountProviderId: 'google-service-account', }, }, defaultService: 'gmail', From 93739dfd6d1fb626968dbb6ff65a62cb97bb2362 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:32:03 -0800 Subject: [PATCH 14/83] fix(copilot): let Copilot pass a saved credential to hybrid tools Copilot's integration schema only offered credentialId for tools that require OAuth, so a tool that takes an inline key or an optional saved credential always demanded the inline secret and the gateway never forwarded a saved credential. On the copilot surface those tools now declare an optional credentialId and stop requiring the params a saved credential supplies; the workflow surface and OAuth-required tools are unchanged. The executor now rejects a Copilot call to such a tool that sends neither, instead of calling the provider without a key. --- apps/sim/tools/index.test.ts | 30 +++++++++++++++++++++++++ apps/sim/tools/index.ts | 18 ++++++++++++++- apps/sim/tools/params.test.ts | 42 +++++++++++++++++++++++++++++++++++ apps/sim/tools/params.ts | 22 +++++++++++++++++- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4534fed46d0..664864fe46a 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -3987,6 +3987,36 @@ describe('Internal Route Trust', () => { }) expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() }) + + it('makes copilot pass credentialId or the inline values, and honors credentialId alone', async () => { + const _context = { + workspaceId: 'workspace-456', + userId: 'user-1', + copilotToolExecution: true, + } + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'saved-token', + credentialType: 'service_account', + domain: 'api.example.com', + }) + + const missing = await executeTool(toolId, { baseUrl: 'api.example.com', _context }) + expect(missing).toMatchObject({ + success: false, + error: expect.stringMatching(/credentialId.*apiKey/), + }) + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + + const saved = await executeTool(toolId, { credentialId: 'saved-credential', _context }) + expect(saved.success).toBe(true) + expect(mockSecureFetchWithPinnedIP).toHaveBeenLastCalledWith( + 'https://api.example.com/v1/me', + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer saved-token' }), + }) + ) + }) }) it.each(['oauth', 'managed_oauth', undefined])( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f51e68b15f6..8df5f9ddbe5 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -523,7 +523,7 @@ function enforceCopilotCredentialSelection( params: Record, scope: ToolExecutionScope ): void { - if (!scope.copilotToolExecution || !tool.oauth?.required) { + if (!scope.copilotToolExecution || !tool.oauth) { return } @@ -532,6 +532,22 @@ function enforceCopilotCredentialSelection( } const toolLabel = tool.name || toolId + if (!tool.oauth.required) { + // Copilot's schema lets credentialId stand in for these, so it no longer requires them. + const missing = (tool.oauth.credentialSuppliedParams ?? []).filter( + (paramId) => + tool.params[paramId]?.required && + !(isHosted && paramId === tool.hosting?.apiKeyParam) && + (params[paramId] === undefined || params[paramId] === null || params[paramId] === '') + ) + if (missing.length > 0) { + throw new Error( + `${toolLabel} needs credentialId (a saved ${tool.oauth.provider} credential) or ${missing.join(', ')}.` + ) + } + return + } + throw new Error( `Copilot must pass credentialId for ${toolLabel}. Run \`credentials list\` and pass the exact credentialId for provider "${tool.oauth.provider}".` ) diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index de88796c2ca..94cb98091f1 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -12,6 +12,7 @@ import { type ToolSchema, ToolSchemaEnrichmentError, } from '@/tools/params' +import { tokenCredentialOAuth } from '@/tools/shared/saved-credentials' import type { HttpMethod, ParameterVisibility } from '@/tools/types' const mockToolConfig = { @@ -466,6 +467,47 @@ describe('Tool Parameters Utils', () => { expect(copilotSchema.required).toContain('credentialId') }) + it.concurrent( + 'lets copilot pass a saved credential instead of the inline values it supplies', + () => { + const hybridTool = { + ...mockToolConfig, + id: 'hybrid_schema_tool', + oauth: tokenCredentialOAuth('databricks', { apiKey: 'accessToken', host: 'domain' }), + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Workspace host', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only' as ParameterVisibility, + description: 'Access token', + }, + warehouseId: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Warehouse ID', + }, + }, + } + + const copilotSchema = createUserToolSchema(hybridTool, { surface: 'copilot' }) + const workflowSchema = createUserToolSchema(hybridTool) + + expect(copilotSchema.properties.credentialId).toMatchObject({ type: 'string' }) + expect(copilotSchema.required).toEqual(['warehouseId']) + expect(copilotSchema.properties).toHaveProperty('apiKey') + expect(copilotSchema.properties).toHaveProperty('host') + expect(workflowSchema.properties).not.toHaveProperty('credentialId') + expect(workflowSchema.required).toEqual(['host', 'apiKey', 'warehouseId']) + } + ) + it.concurrent('emits file params as reference strings by default', () => { const toolWithFileParams = { ...mockToolConfig, diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index a5fc3f4e4f9..3fa9462a0b1 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -278,6 +278,12 @@ export function createUserToolSchema( properties: {}, required: [], } + // A hybrid tool takes either its inline values or a saved credential that + // supplies them, so Copilot may send credentialId in place of those values. + const savedCredentialParams = + surface === 'copilot' && toolConfig.oauth?.required === false + ? (toolConfig.oauth.credentialSuppliedParams ?? []) + : undefined for (const [paramId, param] of Object.entries(toolConfig.params)) { if (!param) continue @@ -307,11 +313,25 @@ export function createUserToolSchema( } schema.properties[paramId] = propertySchema - if (param.required && paramId !== hostedApiKeyParam) { + if ( + param.required && + paramId !== hostedApiKeyParam && + !savedCredentialParams?.includes(paramId) + ) { schema.required.push(paramId) } } + const credentialReplaces = savedCredentialParams?.filter( + (paramId) => paramId in schema.properties + ) + if (credentialReplaces?.length) { + schema.properties.credentialId = { + type: 'string', + description: `Optional ID of a saved credential to use for this call, from environment/credentials.json. It supplies ${credentialReplaces.join(', ')}; without it, pass those yourself.`, + } + } + if (toolConfig.oauth?.required && surface === 'copilot') { schema.properties.credentialId = { type: 'string', From 11049d1cbe97ca5c1c1025da6c8d6f20a23f3076 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:33:22 -0800 Subject: [PATCH 15/83] fix(credentials): report a NerdGraph error answer as New Relic being unavailable New Relic refuses an unknown or wrong-region User key with a 401, so a 200 whose errors array left the fixed actor query unanswered is NerdGraph failing, not a verdict on the key. Report it as provider_unavailable instead of telling the user their key is wrong. A 200 with no user and no errors still rejects. --- .../validators/token-checks.test.ts | 28 +++++++++++++++++++ .../validators/token-checks.ts | 17 +++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 8a86d931549..b4d0877bbc1 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -210,6 +210,34 @@ describe('AgentMail saved API key verification', () => { }) }) +describe('New Relic saved User key verification', () => { + it('reports a NerdGraph error answer without a user as an outage, not a bad key', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ data: { actor: null }, errors: [{ message: 'Internal Server Error' }] }) + ) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-valid', domain: 'us' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('still rejects a NerdGraph answer that resolves no user and reports no error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: { actor: { user: null } } })) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-unknown', domain: 'us' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) +}) + describe('Ashby saved API key verification', () => { it('accepts a valid key that lacks the read permission for this endpoint', async () => { vi.stubGlobal( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 846638338e7..9bd36168745 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -1034,8 +1034,9 @@ export const validateMem0ServiceAccount = tokenCheckValidator({ * A New Relic User API key is bound to its data center, so it is checked against the * NerdGraph host of the region typed with it (`us` or `eu`, stored lower-cased because * the tools send anything but `eu` to the US host). An unknown key, or one from the other - * region, gets 401; NerdGraph can also answer 200 with an `errors` array, so only a - * resolved user proves the key. + * region, gets 401, so only a resolved user proves the key. A 200 whose `errors` array + * left the user unresolved is NerdGraph failing to answer this fixed query, not a verdict + * on the key. */ export const validateNewRelicServiceAccount = tokenCheckValidator({ serviceLabel: 'New Relic', @@ -1046,7 +1047,17 @@ export const validateNewRelicServiceAccount = tokenCheckValidator({ headers: { ...newRelicHeaders(apiToken), Accept: 'application/json' }, body: JSON.stringify({ query: '{ actor { user { id } } }' }), }), - acceptsBody: (body) => toRecord(toRecord(toRecord(body).data).actor).user != null, + acceptsBody: (body) => { + const { data, errors } = toRecord(body) + if (toRecord(toRecord(data).actor).user != null) return true + if (toArray(errors).length > 0) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'NerdGraph answered with errors and no user', + }) + } + return false + }, }) /** From ea1147741c57ade4ac7837b2f84523028b81a56e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:34:29 -0800 Subject: [PATCH 16/83] fix(aws): refuse S3 presigned URLs that outlive the saved role session signing them A presigned URL signed with STS credentials stops working when the role session ends, whatever expiry it asks for, but the S3 tool reported the requested expiry. Resolved sessions now carry the STS expiration, and presigning with a saved assume_role or oidc_role credential refuses an expiry past the session's end with a 400 that names the remaining time and the session duration. Long-lived access keys and inline keys are unchanged. --- .../aws-service-account.server.test.ts | 28 +++++++++ .../credentials/aws-service-account.server.ts | 4 ++ apps/sim/lib/internal/aws/credentials.ts | 12 ++++ apps/sim/lib/internal/s3/operations.test.ts | 60 ++++++++++++++++++- apps/sim/lib/internal/s3/operations.ts | 14 ++++- 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts index 97b4a329854..2f54a38e275 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -26,6 +26,7 @@ import { AssumeRoleWithWebIdentityCommand, GetCallerIdentityCommand } from '@aws import { credential } from '@sim/db/schema' import { getAwsCredentialConfig, + resolveAwsSessionCredentials, verifyAndEncryptAwsCredential, } from '@/lib/credentials/aws-service-account.server' import { WorkloadIdentityNotConfiguredError } from '@/lib/credentials/workload-identity/subject' @@ -155,3 +156,30 @@ describe('loading a saved AWS credential for a run', () => { ) }) }) + +describe("resolving a run's AWS session", () => { + const owner = { workspaceId: 'ws-1' } + const options = { roleSessionName: 'sim-exec-1' } + const expiration = new Date('2030-01-01T01:00:00.000Z') + + beforeEach(() => hoisted.send.mockReset()) + + it.each([ + { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT }, + { + authType: 'assume_role', + roleArn: ROLE_ARN, + accessKeyId: 'AKIAEXAMPLEKEY01', + secretAccessKey: 'stored-secret', + }, + ] as const)('reports when a $authType session ends', async (config) => { + hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) + + await expect(resolveAwsSessionCredentials(config, owner, options)).resolves.toEqual({ + accessKeyId: 'ASIATEMP', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration, + }) + }) +}) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index 4995824060c..b75d152572e 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -34,6 +34,7 @@ import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * sessions resolved from them stay out of block outputs, logs, and model input, * with two exceptions: an S3 presigned URL made with temporary credentials * carries the temporary access key ID and session token (never the secret), and + * stops working when that session ends, so its expiry can't outlast the session; * operations whose purpose is to issue AWS credentials (STS assume role and get * session token, IAM create access key) return the credentials they issue, as * the same operations do with inline keys. Using a credential therefore includes @@ -126,6 +127,8 @@ export interface AwsSessionCredentials { accessKeyId: string secretAccessKey: string sessionToken?: string + /** When an STS session ends; long-lived access keys have none. */ + expiration?: Date } /** STS lives in every partition; a role's partition decides the default endpoint. */ @@ -153,6 +156,7 @@ function toSessionCredentials(credentials: Credentials | undefined): AwsSessionC accessKeyId: credentials.AccessKeyId, secretAccessKey: credentials.SecretAccessKey, sessionToken: credentials.SessionToken, + ...(credentials.Expiration ? { expiration: credentials.Expiration } : {}), } } diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index 8e05d583eef..e7f877e9f8d 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -51,6 +51,18 @@ export function awsClientCredentials( return { accessKeyId, secretAccessKey } } +/** + * When the STS session behind `accessKeyId` ends, or undefined for long-lived + * keys. Anything signed with a session, like an S3 presigned URL, stops + * working at this time whatever expiry it asked for. + */ +export function awsSessionExpiration(accessKeyId: string): Date | undefined { + const session = sessionStorage.getStore() + return session?.sessionToken && session.accessKeyId === accessKeyId + ? session.expiration + : undefined +} + /** * SDK client settings for a client built from an operation's key fields: its region, * {@link awsClientCredentials}, and the request handler that honors `HTTPS_PROXY`. diff --git a/apps/sim/lib/internal/s3/operations.test.ts b/apps/sim/lib/internal/s3/operations.test.ts index c5e1b443f1b..1fb7317b81f 100644 --- a/apps/sim/lib/internal/s3/operations.test.ts +++ b/apps/sim/lib/internal/s3/operations.test.ts @@ -7,7 +7,7 @@ import { filesAuthorizationMock, filesAuthorizationMockFns, } from '@sim/testing/mocks/files-authorization.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createS3Client: vi.fn(), @@ -26,8 +26,9 @@ vi.mock('@/app/api/files/authorization', () => filesAuthorizationMock) vi.mock('@/lib/uploads/utils/file', () => fileUtilsMock) vi.mock('@/lib/uploads/utils/file.server', () => fileUtilsServerMock) +import { runWithAwsSession } from '@/lib/internal/aws/credentials' import { S3OperationError } from '@/lib/internal/s3/errors' -import { executeS3PutObject } from '@/lib/internal/s3/operations' +import { executeS3PresignedUrl, executeS3PutObject } from '@/lib/internal/s3/operations' const { mockAssertToolFileAccess } = filesAuthorizationMockFns const { mockDownloadServableFileFromStorage } = fileUtilsServerMockFns @@ -78,3 +79,58 @@ describe('S3 operations', () => { expect(mocks.createS3Client).not.toHaveBeenCalled() }) }) + +describe('S3 presigned URLs', () => { + const NOW = new Date('2030-01-01T00:00:00.000Z') + const SESSION = { + accessKeyId: 'ASIASESSIONKEY', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + expiration: new Date(NOW.getTime() + 3600 * 1000), + } + const presign = (accessKeyId: string, expiresIn: number) => + executeS3PresignedUrl({ + accessKeyId, + secretAccessKey: 'secret', + region: 'us-east-1', + bucketName: 'bucket', + objectKey: 'report.csv', + method: 'get', + expiresIn, + }) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + mocks.createS3Client.mockReturnValue({ send: mocks.send, destroy: mocks.destroy }) + mocks.getSignedUrl.mockResolvedValue( + 'https://bucket.s3.amazonaws.com/report.csv?X-Amz-Signature=x' + ) + }) + afterEach(() => vi.useRealTimers()) + + it('refuses an expiry past the end of the role session that signs it', async () => { + await expect( + runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 7200)) + ).rejects.toMatchObject({ + status: 400, + message: expect.stringMatching(/3600 seconds.*session duration/), + }) + expect(mocks.getSignedUrl).not.toHaveBeenCalled() + }) + + it('reports an expiry the role session outlives', async () => { + const result = await runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 3600)) + + expect(result.output.expiresAt).toBe('2030-01-01T01:00:00.000Z') + expect(mocks.getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + expiresIn: 3600, + }) + }) + + it('keeps the full seven days for long-lived access keys', async () => { + const result = await presign('AKIASTATICKEY', 604800) + + expect(result.output.expiresAt).toBe('2030-01-08T00:00:00.000Z') + }) +}) diff --git a/apps/sim/lib/internal/s3/operations.ts b/apps/sim/lib/internal/s3/operations.ts index 948b89d2435..c7d69f7185a 100644 --- a/apps/sim/lib/internal/s3/operations.ts +++ b/apps/sim/lib/internal/s3/operations.ts @@ -28,6 +28,7 @@ import type { AwsS3ListObjectsBody } from '@/lib/api/contracts/tools/aws/s3-list import type { AwsS3PresignedUrlBody } from '@/lib/api/contracts/tools/aws/s3-presigned-url' import type { AwsS3PutObjectBody } from '@/lib/api/contracts/tools/aws/s3-put-object' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { awsSessionExpiration } from '@/lib/internal/aws/credentials' import { createS3Client, type S3ConnectionConfig } from '@/lib/internal/s3/client' import { S3OperationError } from '@/lib/internal/s3/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -281,6 +282,17 @@ export async function executeS3ListObjects(input: AwsS3ListObjectsBody, signal?: } export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal?: AbortSignal) { + const now = Date.now() + const sessionExpiration = awsSessionExpiration(input.accessKeyId) + if (sessionExpiration) { + const sessionSeconds = Math.floor((sessionExpiration.getTime() - now) / 1000) + if (input.expiresIn > sessionSeconds) { + throw new S3OperationError( + `This presigned URL would stop working when the AWS role session that signs it ends, in ${sessionSeconds} seconds. Set an expiry of ${sessionSeconds} seconds or less, or give the credential a longer session duration.`, + 400 + ) + } + } return withS3Client(input, async (client) => { signal?.throwIfAborted() const command = @@ -299,7 +311,7 @@ export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal url, method: input.method, expiresIn: input.expiresIn, - expiresAt: new Date(Date.now() + input.expiresIn * 1000).toISOString(), + expiresAt: new Date(now + input.expiresIn * 1000).toISOString(), }, } }) From 20c954e55671e0611de8ac517f504931404cb825 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:34:42 -0800 Subject: [PATCH 17/83] fix(guardrails): resolve the hallucination check's Vertex credential like every other model call The hallucination check looked the Vertex credential up as a raw account ID, so a saved credential (whose ID is a credential row, OAuth or Google service account) resolved no token and the scoring call ran without one. It now uses the shared Vertex resolver, which handles credential rows, Google service accounts, and the legacy account IDs the old lookup accepted, and fails the check instead of calling the model when the credential cannot be resolved. --- .../guardrails/validate-hallucination.test.ts | 62 ++++++++++++++++++- .../lib/guardrails/validate-hallucination.ts | 26 ++------ 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/apps/sim/lib/guardrails/validate-hallucination.test.ts b/apps/sim/lib/guardrails/validate-hallucination.test.ts index f3ebfa761c6..61b5a60b55f 100644 --- a/apps/sim/lib/guardrails/validate-hallucination.test.ts +++ b/apps/sim/lib/guardrails/validate-hallucination.test.ts @@ -1,11 +1,16 @@ import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { providersMock, providersMockFns } from '@sim/testing/mocks/providers.mock' -import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' -const { mockSearchKnowledgeAsExecutor } = vi.hoisted(() => ({ +const { mockSearchKnowledgeAsExecutor, mockResolveVertexAccessToken } = vi.hoisted(() => ({ mockSearchKnowledgeAsExecutor: vi.fn(), + mockResolveVertexAccessToken: vi.fn(), +})) + +vi.mock('@/lib/internal/llm/credentials', () => ({ + resolveVertexAccessToken: mockResolveVertexAccessToken, })) vi.mock('@/lib/internal/knowledge/search', () => ({ @@ -165,6 +170,59 @@ describe('validateHallucination', () => { expect(mockExecuteProviderRequest).not.toHaveBeenCalled() }) + describe('with a Vertex AI model', () => { + const vertexInput = (registry: ResolvedSecretTraceRegistry) => ({ + ...createInput(registry), + model: 'vertex/gemini-2.5-pro', + apiKey: 'unused-key', + providerCredentials: { + vertexCredential: 'credential-vertex', + vertexProject: 'example-project', + vertexLocation: 'us-central1', + }, + }) + + beforeEach(() => { + mockResolveVertexAccessToken.mockReset() + mockResolveVertexAccessToken.mockImplementation( + async (_requestId: string, credentialId: string) => `access-token-for:${credentialId}` + ) + }) + + it('scores with the access token the saved credential resolves to', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('vertex') + + const result = await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(result).toMatchObject({ passed: true, score: 8 }) + expect(mockExecuteProviderRequest.mock.calls[0]?.[1]).toMatchObject({ + apiKey: 'access-token-for:credential-vertex', + vertexProject: 'example-project', + vertexLocation: 'us-central1', + }) + }) + + it('fails the check instead of calling the model when the credential cannot be resolved', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('vertex') + mockResolveVertexAccessToken.mockRejectedValue( + new Error('Vertex AI credential not found: credential-vertex') + ) + + const result = await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(result).toMatchObject({ passed: false }) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('leaves another provider’s key alone', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('openai') + + await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(mockExecuteProviderRequest.mock.calls[0]?.[1]).toMatchObject({ apiKey: 'unused-key' }) + }) + }) + /** * Forwarding the caller's signal means the scoring model can now be aborted. A * cancelled run must not be reported as a guardrail verdict — `passed: false` would diff --git a/apps/sim/lib/guardrails/validate-hallucination.ts b/apps/sim/lib/guardrails/validate-hallucination.ts index d87d1387070..b294c3dea0d 100644 --- a/apps/sim/lib/guardrails/validate-hallucination.ts +++ b/apps/sim/lib/guardrails/validate-hallucination.ts @@ -1,13 +1,10 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isPlainRecord } from '@sim/utils/object' -import { eq } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' import { searchKnowledgeAsExecutor } from '@/lib/internal/knowledge/search' +import { resolveVertexAccessToken } from '@/lib/internal/llm/credentials' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -159,22 +156,11 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` const providerId = getProviderFromModel(model) - let finalApiKey: string | undefined = apiKey - if (providerId === 'vertex' && providerCredentials?.vertexCredential) { - const credential = await db.query.account.findFirst({ - where: eq(account.id, providerCredentials.vertexCredential), - }) - if (credential) { - const { accessToken } = await refreshTokenIfNeeded( - requestId, - credential, - providerCredentials.vertexCredential - ) - if (accessToken) { - finalApiKey = accessToken - } - } - } + // The guardrails operation authorized this credential before validation began. + const finalApiKey = + providerId === 'vertex' && providerCredentials?.vertexCredential + ? await resolveVertexAccessToken(requestId, providerCredentials.vertexCredential) + : apiKey const response = await executeProviderRequest( providerId, From 01e0905f55ec0f9148104c51bb0f0798833380be Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:36:16 -0800 Subject: [PATCH 18/83] fix(aws): report transient STS failures on a saved credential as 503, not a denial Every failure to resolve a saved AWS credential came back as a 403, so an STS outage, throttling, or STS failing to reach Sim's OIDC issuer (IDPCommunicationError) read as a permission problem and was never retried. Failures STS models as retryable, throttling, STS 5xx, and network errors now return 503; real denials (AccessDenied, invalid or expired identity tokens, rejected claims) stay 403. No extra retry loop is added: the pinned STS client already retries IDPCommunicationError and throttling up to three times with backoff. --- apps/sim/lib/internal/aws/credentials.test.ts | 43 +++++++++++++++++++ apps/sim/lib/internal/aws/credentials.ts | 33 +++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/internal/aws/credentials.test.ts b/apps/sim/lib/internal/aws/credentials.test.ts index 72f2c4d9647..b0713431012 100644 --- a/apps/sim/lib/internal/aws/credentials.test.ts +++ b/apps/sim/lib/internal/aws/credentials.test.ts @@ -18,6 +18,12 @@ vi.mock('@/lib/credentials/aws-service-account.server', () => ({ resolveAwsSessionCredentials: mockResolveAwsSessionCredentials, })) +import { + ExpiredTokenException, + IDPCommunicationErrorException, + IDPRejectedClaimException, + InvalidIdentityTokenException, +} from '@aws-sdk/client-sts' import { awsClientCredentials, withAwsCredential } from '@/lib/internal/aws/credentials' function call(overrides: Partial = {}): InternalToolOperationCall { @@ -157,4 +163,41 @@ describe('withAwsCredential', () => { expect(response.status).toBe(403) expect(handler).not.toHaveBeenCalled() }) + + const stsFailure = (name: string, httpStatusCode: number) => + Object.assign(new Error(`${name} from STS`), { name, $metadata: { httpStatusCode } }) + const sdkFailure = (Exception: typeof IDPCommunicationErrorException) => + new Exception({ message: 'from STS', $metadata: { httpStatusCode: 400 } }) + + it.each([ + ['STS could not reach the OIDC issuer', sdkFailure(IDPCommunicationErrorException), 503], + ['STS throttled the exchange', stsFailure('Throttling', 400), 503], + ['STS failed on its side', stsFailure('InternalFailure', 500), 503], + [ + 'the connection to STS reset', + Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }), + 503, + ], + ['the role denies the session', stsFailure('AccessDenied', 403), 403], + ['STS rejects the identity token', sdkFailure(InvalidIdentityTokenException), 403], + ['the identity token expired', sdkFailure(ExpiredTokenException), 403], + ['the issuer rejected the claim', sdkFailure(IDPRejectedClaimException), 403], + ])('maps a failure where %s to the right status', async (_case, error, status) => { + mockGetAwsCredentialConfig.mockResolvedValue({ config: {}, owner: { workspaceId: 'ws-1' } }) + mockResolveAwsSessionCredentials.mockRejectedValue(error) + const handler = vi.fn() + + const response = (await withAwsCredential(handler)( + call({ + context: { + workflowId: 'wf-1', + workspaceId: 'ws-1', + authorizedServiceAccountCredential: { credentialId: 'cred-1', service: 'aws' }, + }, + }) + )) as Response + + expect(response.status).toBe(status) + expect(handler).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index e7f877e9f8d..3f67af95011 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -106,6 +106,37 @@ export async function resolveAuthorizedAwsCredential( }) } +const THROTTLING_ERROR_NAMES = new Set(['Throttling', 'ThrottlingException']) +const NETWORK_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', + 'EAI_AGAIN', +]) + +/** + * Whether resolving a credential failed because AWS was briefly unavailable rather + * than because it denied the credential: an error STS models as retryable + * (IDPCommunicationError, when STS could not reach Sim's OIDC issuer), throttling, + * an STS 5xx, or a network failure. The SDK has already retried these. + */ +function isTransientAwsFailure(error: unknown): boolean { + if (!isRecordLike(error)) return false + const status = isRecordLike(error.$metadata) ? error.$metadata.httpStatusCode : undefined + return ( + error.$retryable !== undefined || + THROTTLING_ERROR_NAMES.has(String(error.name)) || + status === 429 || + (typeof status === 'number' && status >= 500) || + error.name === 'TimeoutError' || + NETWORK_ERROR_CODES.has(String(error.code)) + ) +} + /** Wraps a family's operation handler so it also accepts a saved AWS credential. */ export function withAwsCredential( handler: InternalToolOperationHandler @@ -126,7 +157,7 @@ export function withAwsCredential( request.signal?.throwIfAborted() return Response.json( { error: `Could not use the AWS credential: ${getErrorMessage(error, 'Unknown error')}` }, - { status: 403 } + { status: isTransientAwsFailure(error) ? 503 : 403 } ) } request.signal?.throwIfAborted() From 4c9eb9ca992d45a7787c78302d4abb38c105f129 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:37:12 -0800 Subject: [PATCH 19/83] fix(databricks): report an unreachable workspace at token mint as provider_unavailable A connection failure, timeout, or refused redirect while minting a run's Databricks token escaped as a raw fetch error, which the executor reported as a generic 401 and whose cause can carry connection details. It is now a 502 DatabricksCredentialError, which resolves to provider_unavailable; a cancelled run still rethrows its abort, and personal access tokens are unaffected. --- .../databricks-service-account.server.test.ts | 35 +++++++++++++++++++ .../databricks-service-account.server.ts | 24 ++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index 26b610ba9e5..dd60ad1426f 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -238,4 +238,39 @@ describe('getting a Databricks token for a run', () => { code: 'provider_unavailable', }) }) + + it.each([ + [ + 'the connection fails', + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('connect ECONNREFUSED 203.0.113.7:443'), { + code: 'ECONNREFUSED', + }), + }), + ], + ['the workspace answers with a redirect', new TypeError('fetch failed: unexpected redirect')], + ['the request times out', new DOMException('The operation timed out.', 'TimeoutError')], + ])( + 'reports a workspace that cannot be reached when %s as unavailable, without connection details', + async (_case, failure) => { + fetch.mockRejectedValue(failure) + + const error = await resolveDatabricksCredentialToken('cred-1').catch((caught) => caught) + + expect(error).toMatchObject({ code: 'provider_unavailable', status: 502 }) + expect(JSON.stringify(error)).not.toContain('203.0.113.7') + } + ) + + it('rethrows when the run itself is cancelled', async () => { + const controller = new AbortController() + fetch.mockImplementation(async () => { + controller.abort() + throw new DOMException('This operation was aborted', 'AbortError') + }) + + await expect( + resolveDatabricksCredentialToken('cred-1', controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) }) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 287dd4ff731..cb5ab40422b 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -140,14 +140,22 @@ async function requestWorkspaceToken( headers: Record = {} ): Promise { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) - const response = await fetch(`${workspaceOrigin(host)}/oidc/v1/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, - body, - // A secret or subject token must never follow a redirect off the workspace host. - redirect: 'error', - signal: signal ? AbortSignal.any([signal, timeout]) : timeout, - }) + const url = `${workspaceOrigin(host)}/oidc/v1/token` + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body, + // A secret or subject token must never follow a redirect off the workspace host. + redirect: 'error', + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }) + } catch { + signal?.throwIfAborted() + // A network failure, timeout, or refused redirect says nothing about the credential. + throw new DatabricksCredentialError('Could not reach the Databricks workspace. Try again.', 502) + } const payload = (await readResponseJsonWithLimit(response, { maxBytes: MAX_RESPONSE_BYTES, label: 'Databricks token response', From 4aa71faa0aa27e8d1c884a66da05cad307f8eb10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:37:41 -0800 Subject: [PATCH 20/83] feat(credentials): connect Shopify Dev Dashboard apps with client credentials Shopify Dev Dashboard apps never reveal an Admin API token, so the Shopify saved credential gains a client-credentials mode beside the pasted shpat_ token. A credential saved with authMethod client_credentials stores the client ID, secret, and store domain; execution mints the 24-hour token at the store's /admin/oauth/access_token endpoint. Shopify does not document whether a new token retires earlier ones, so every saved credential for one app and store shares a single token kept under an advisory lock in client_credential_token, instead of minting per worker. shop_not_permitted surfaces as grant_not_permitted with copy that names the same-organization requirement. Pasted-token credentials, their blob, and their resolution are unchanged; the minted-token cache in the credential service is factored out so the client-credential providers and this mode share it. --- .../integrations/shopify-service-account.mdx | 38 +++- .../token-service-account-modal.tsx | 77 ++++++- .../credentials/coordinated-client-token.ts | 207 ++++++++++++++++++ .../service-account-fields.test.ts | 35 +++ .../lib/credentials/service-account-fields.ts | 35 ++- .../service-account-secret.test.ts | 103 +++++++++ .../lib/credentials/service-account-secret.ts | 67 ++++++ .../client-credentials.ts | 56 +++++ .../token-service-accounts/descriptors.ts | 73 +++++- .../token-service-accounts/errors.ts | 6 + .../grants/shopify.test.ts | 130 +++++++++++ .../token-service-accounts/grants/shopify.ts | 134 ++++++++++++ .../token-service-accounts/server.ts | 83 ++++++- .../validators/shopify.test.ts | 1 + .../validators/shopify.ts | 5 +- apps/sim/lib/oauth/credential-service.test.ts | 91 ++++++++ apps/sim/lib/oauth/credential-service.ts | 162 ++++++++++---- apps/sim/lib/oauth/token-resolution.ts | 9 + 18 files changed, 1241 insertions(+), 71 deletions(-) create mode 100644 apps/sim/lib/credentials/coordinated-client-token.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/client-credentials.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts diff --git a/apps/docs/content/docs/integrations/shopify-service-account.mdx b/apps/docs/content/docs/integrations/shopify-service-account.mdx index 93660d04247..11f68eb1204 100644 --- a/apps/docs/content/docs/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/integrations/shopify-service-account.mdx @@ -1,12 +1,15 @@ --- title: Shopify Admin API tokens -description: Set up a Shopify custom app with an Admin API access token to use Shopify in Sim workflows +description: Connect a Shopify Dev Dashboard app with client credentials, or a custom app with an Admin API access token, to use Shopify in Sim workflows --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Connect a Shopify store with a **non-expiring offline Admin API access token** and its `.myshopify.com` domain. Sim stores the pasted token; this connection does not renew expiring tokens. +Connect a Shopify store in one of two ways, both with the store's `.myshopify.com` domain: + +- **Client credentials (Dev Dashboard app)**: the app's client ID and secret. Sim requests a 24-hour Admin API token from Shopify when a workflow needs one and renews it before it expires. Use this for apps created in the Dev Dashboard, which never show an Admin API token. +- **Admin API access token**: a **non-expiring offline** `shpat_` token pasted once. Sim stores the pasted token and does not renew expiring tokens. @@ -14,7 +17,28 @@ Connect a Shopify store with a **non-expiring offline Admin API access token** a You need store owner or staff permissions that allow managing apps on the store. You'll also need your store's permanent domain (`your-store.myshopify.com`) — Sim asks for it alongside the token. -Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly. +Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI; connect those with client credentials. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly. + +## Connect a Dev Dashboard app with client credentials [#client-credentials] + +Shopify's [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant) only works when the app and the store belong to the **same Shopify organization**. If they don't, Shopify refuses the grant (`shop_not_permitted`) and Sim tells you so when you save the connection. + + + + In the [Dev Dashboard](https://dev.shopify.com/), create an app, configure the Admin API access scopes listed below, and install it on your store + + + Copy the app's **Client ID** and **Client secret** from its settings + + + In Sim, open **Integrations**, search for "Shopify", click **Add to Sim**, and choose **Add admin API token**. Leave **Authentication** on **Client credentials (Dev Dashboard app)**, then paste the client ID, the client secret, and the store domain + + + Save. Sim requests a token from Shopify and runs a scope-free `shop` query with it before saving. The client ID, client secret, and store domain are encrypted; the short-lived token is never stored with the credential + + + +Tokens carry every scope the store granted the app; Sim never requests a narrower set. Rotating the client secret in the Dev Dashboard means reconnecting the credential in Sim with the new secret. ## Set up the custom app [#setting-up-the-custom-app] @@ -48,10 +72,10 @@ Shopify no longer allows creating new custom apps from the store admin — that How you obtain the `shpat_` token depends on how the app was created: - **Legacy admin-created custom apps** (created from the store admin before Shopify removed that path) show the **Admin API access token** — it starts with `shpat_` — once on the app's API credentials page. Reveal it, copy it immediately, and paste it into Sim. For legacy admin-created custom apps, Shopify shows the token only once, at creation — if you lose it, you have to reinstall or recreate the app to get a new one. -- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Complete an [authorization code grant](https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps) install and obtain a **non-expiring offline** access token. Check the token response and access mode: the `shpat_` prefix is also used by expiring tokens. The Dev Dashboard's client-credentials tokens are different — they expire after 24 hours and cannot be stored in Sim. +- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Connect them with [client credentials](#client-credentials) instead. Don't paste a 24-hour client-credentials token here: it stops working after a day. -Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` identifies an older custom-app token format. Use the documented Admin API token for your app and confirm its lifetime; a client secret is not an access token. If your Dev Dashboard app only shows a client ID and secret, that is expected: Dev Dashboard apps issue tokens via OAuth, not a UI reveal (see the setup steps above). +Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` identifies an older custom-app token format. Use the documented Admin API token for your app and confirm its lifetime; a client secret is not an access token. If your Dev Dashboard app only shows a client ID and secret, that is expected: choose **Client credentials** and paste those instead. ### 3. Find your store domain [#3-find-your-store-domain] @@ -68,7 +92,7 @@ Use the permanent `.myshopify.com` domain — for example, `your-store.myshopify Search for "Shopify" and open it, then click **Add to Sim** and choose **Add admin API token** - Paste the Admin API access token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description + Set **Authentication** to **Admin API access token (shpat_)**, paste the token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description Click **Add admin API token**. Sim verifies the token with a scope-free `shop` query on the store’s GraphQL Admin API — if it fails, you'll see a specific error explaining what went wrong. @@ -85,7 +109,7 @@ The block calls your store's Admin API (`your-store.myshopify.com/admin/api/...` ## Token lifecycle and recovery -A pasted credential must use a non-expiring offline token; Sim does not renew online, expiring offline, or 24-hour client-credentials tokens. A `shpat_` prefix alone does not establish the token’s lifetime. See [Shopify’s access-token reference](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens). +A pasted credential must use a non-expiring offline token; Sim does not renew a pasted online, expiring offline, or 24-hour client-credentials token. Connect a Dev Dashboard app with client credentials instead, and Sim renews its tokens itself. A `shpat_` prefix alone does not establish the token’s lifetime. See [Shopify’s access-token reference](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens). Tokens are scoped to the app’s installation on a store. Add a separate credential for each store. If an operation fails with access denied, review the app’s scopes and the store’s approval for protected data. Apply the scope change and re-authorize as required; update Sim if the procedure issues a replacement token. diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index 4822a473ae8..5b08fb90eb0 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -21,6 +21,7 @@ import { import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' import { getTokenServiceAccountErrorMessage, + TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, type TokenServiceAccountDescriptor, type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' @@ -39,6 +40,8 @@ function normalizeDomainInput(raw: string): string { .replace(/[/?#].*$/, '') } +type TokenAuthMethod = 'token' | typeof TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + function openDocs(url: string): void { window.open(url, '_blank', 'noopener,noreferrer') } @@ -95,7 +98,15 @@ function TokenServiceAccountModalForm({ onSubmitCredentials, submitPending, }: TokenServiceAccountModalProps) { + const clientCredentials = descriptor.clientCredentials + const [selectedAuthMethod, setSelectedAuthMethod] = useState(null) + const authMethod: TokenAuthMethod = + selectedAuthMethod ?? + (clientCredentials?.preferred && !credentialId ? TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD : 'token') + const usesClientCredentials = authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD const [apiToken, setApiToken] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') const [domain, setDomain] = useState('') const [providerWorkspaceId, setProviderWorkspaceId] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') @@ -114,7 +125,7 @@ function TokenServiceAccountModalForm({ const isPending = createCredential.isPending || updateCredential.isPending || submitPending === true const isDisabled = - !trimmedToken || + (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || (Boolean(domainField) && !normalizedDomain) || (Boolean(workspaceField) && !providerWorkspaceId.trim()) || isPending @@ -129,7 +140,13 @@ function TokenServiceAccountModalForm({ if (isDisabled) return try { const secretFields = { - apiToken: trimmedToken, + ...(usesClientCredentials + ? { + authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + } + : { apiToken: trimmedToken }), ...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}), ...(domainField ? { domain: normalizedDomain } : {}), } @@ -170,7 +187,8 @@ function TokenServiceAccountModalForm({ getTokenServiceAccountErrorMessage( descriptor, code, - onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined + onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined, + authMethod ) ) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) @@ -187,7 +205,58 @@ function TokenServiceAccountModalForm({ Add {serviceName} {descriptor.connectNoun} - {tokenField && ( + {clientCredentials && ( + { + setSelectedAuthMethod(value as TokenAuthMethod) + if (error) setError(null) + }} + options={[ + { value: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, label: clientCredentials.label }, + { value: 'token', label: clientCredentials.tokenLabel }, + ]} + required + /> + )} + + {clientCredentials && usesClientCredentials && ( + <> + { + setClientId(value) + if (error) setError(null) + }} + placeholder='Paste the client ID' + required + autoComplete='off' + hint={clientCredentials.helpText} + /> + + { + setClientSecret(value) + if (error) setError(null) + }} + placeholder='Paste the client secret' + name={`${descriptor.providerId}_client_secret`} + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + + + )} + + {tokenField && !usesClientCredentials && ( Promise + signal?: AbortSignal +} + +export interface CoordinatedClientToken { + accessToken: string + expiresInSeconds: number +} + +interface StoredApplicationToken { + accessToken: string + secretFingerprint: string +} + +interface StoredFailure { + code: 'invalid_credentials' | 'grant_not_permitted' | 'site_not_found' | 'provider_unavailable' + status: number +} + +const STORED_FAILURE_CODES: ReadonlySet = new Set([ + 'invalid_credentials', + 'grant_not_permitted', + 'site_not_found', + 'provider_unavailable', +]) + +function digest(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +function unavailable(step: string, reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('provider_unavailable', 503, { step, reason }) +} + +function decodeApplicationToken(value: string, step: string): StoredApplicationToken { + const parsed: unknown = JSON.parse(value) + if ( + !isRecordLike(parsed) || + typeof parsed.accessToken !== 'string' || + !parsed.accessToken || + typeof parsed.secretFingerprint !== 'string' + ) + throw unavailable(step, 'stored application token is malformed') + return { accessToken: parsed.accessToken, secretFingerprint: parsed.secretFingerprint } +} + +function decodeFailure(value: string, step: string): StoredFailure { + const parsed: unknown = JSON.parse(value) + if ( + !isRecordLike(parsed) || + typeof parsed.code !== 'string' || + !STORED_FAILURE_CODES.has(parsed.code) || + typeof parsed.status !== 'number' + ) + throw unavailable(step, 'stored token failure is malformed') + return { code: parsed.code as StoredFailure['code'], status: parsed.status } +} + +function remainingSeconds(expiresAt: Date): number { + return Math.floor((expiresAt.getTime() - Date.now()) / 1000) +} + +function isServable(expiresAt: Date): boolean { + return expiresAt.getTime() - Date.now() > MIN_REMAINING_MS +} + +/** + * Serves one client-credentials token per provider application across every saved credential, + * worker, and connect, for providers that do not document whether minting a new token leaves the + * previous one valid. Minting happens only under a transaction-scoped advisory lock keyed by the + * application, so two workers never race two tokens into existence; a token minted with a + * different secret is never served. A rejected exchange is remembered for 30 seconds so a hot + * workflow on a revoked secret does not hammer the token endpoint. Tokens are encrypted at rest. + */ +export async function resolveCoordinatedClientToken( + input: CoordinatedClientTokenInput +): Promise { + const { namespace, step, signal } = input + signal?.throwIfAborted() + const applicationKey = `${namespace}:${digest(input.application)}` + const secretFingerprint = digest([...input.application, input.clientSecret]) + const failureKey = `${applicationKey}:${digest(secretFingerprint)}` + + const serveIfFresh = async (row: typeof clientCredentialToken.$inferSelect | undefined) => { + if (!row || !isServable(row.expiresAt)) return undefined + const stored = decodeApplicationToken((await decryptSecret(row.encryptedValue)).decrypted, step) + if (stored.secretFingerprint !== secretFingerprint) return undefined + return { accessToken: stored.accessToken, expiresInSeconds: remainingSeconds(row.expiresAt) } + } + + try { + const [current] = await db + .select() + .from(clientCredentialToken) + .where(eq(clientCredentialToken.id, applicationKey)) + .limit(1) + const fresh = await serveIfFresh(current) + if (fresh) return fresh + + const outcome = await db.transaction( + async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) + await acquireAdvisoryXactLock(tx, `${namespace}_client_token`, applicationKey) + signal?.throwIfAborted() + const entries = await tx + .select() + .from(clientCredentialToken) + .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) + const minted = await serveIfFresh(entries.find((entry) => entry.id === applicationKey)) + if (minted) return minted + const failure = entries.find( + (entry) => entry.id === failureKey && entry.expiresAt.getTime() > Date.now() + ) + if (failure) { + return { + failure: decodeFailure((await decryptSecret(failure.encryptedValue)).decrypted, step), + } + } + signal?.throwIfAborted() + try { + const exchanged = await input.exchange(signal) + const { encrypted } = await encryptSecret( + JSON.stringify({ accessToken: exchanged.accessToken, secretFingerprint }) + ) + const values = { + encryptedValue: encrypted, + accessTokenDigest: digest([applicationKey, exchanged.accessToken]), + expiresAt: exchanged.expiresAt, + } + await tx + .insert(clientCredentialToken) + .values({ id: applicationKey, ...values }) + .onConflictDoUpdate({ + target: clientCredentialToken.id, + set: { ...values, updatedAt: new Date() }, + }) + await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) + return { + accessToken: exchanged.accessToken, + expiresInSeconds: remainingSeconds(exchanged.expiresAt), + } + } catch (error) { + if (!(error instanceof TokenServiceAccountValidationError)) throw error + const failure: StoredFailure = { + code: STORED_FAILURE_CODES.has(error.code) + ? (error.code as StoredFailure['code']) + : 'provider_unavailable', + status: error.status, + } + const { encrypted } = await encryptSecret(JSON.stringify(failure)) + const expiresAt = new Date(Date.now() + FAILURE_TTL_MS) + await tx + .insert(clientCredentialToken) + .values({ id: failureKey, encryptedValue: encrypted, expiresAt }) + .onConflictDoUpdate({ + target: clientCredentialToken.id, + set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() }, + }) + return { failure } + } + } + ) + signal?.throwIfAborted() + if ('failure' in outcome) { + throw new TokenServiceAccountValidationError(outcome.failure.code, outcome.failure.status, { + step, + }) + } + return outcome + } catch (error) { + signal?.throwIfAborted() + if (error instanceof TokenServiceAccountValidationError) throw error + throw unavailable(step, 'token coordination or exchange failed') + } +} diff --git a/apps/sim/lib/credentials/service-account-fields.test.ts b/apps/sim/lib/credentials/service-account-fields.test.ts index 379241cbc17..9689deb1479 100644 --- a/apps/sim/lib/credentials/service-account-fields.test.ts +++ b/apps/sim/lib/credentials/service-account-fields.test.ts @@ -19,4 +19,39 @@ describe('required fields for a service-account create', () => { getMissingServiceAccountFields('hubspot-service-account', { serviceAccountJson: '{}' }) ).toEqual(['apiToken']) }) + + it('asks a Shopify client-credentials create for the client ID, secret, and store domain', () => { + expect( + getMissingServiceAccountFields('shopify-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + expect( + getMissingServiceAccountFields('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client', + clientSecret: 'secret', + domain: 'acme.myshopify.com', + }) + ).toEqual([]) + }) + + it('still asks a Shopify create without an auth method for the token and store domain', () => { + expect( + getMissingServiceAccountFields('shopify-service-account', { + clientId: 'client', + clientSecret: 'secret', + }) + ).toEqual(['apiToken', 'domain']) + }) + + it('does not let client credentials stand in for a token on a provider without that mode', () => { + expect( + getMissingServiceAccountFields('hubspot-service-account', { + authMethod: 'client_credentials', + clientId: 'client', + clientSecret: 'secret', + }) + ).toEqual(['apiToken']) + }) }) diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 6070efddd76..53358c056ae 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,5 +1,9 @@ import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' -import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' +import { + TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, + TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/token-service-accounts/descriptors' import { isWorkloadIdentityServiceAccountProviderId } from '@/lib/credentials/workload-identity/audiences' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, @@ -103,10 +107,30 @@ function getServiceAccountRequiredFields( : FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS } +/** + * Required create-body fields for providers whose primary mode is a pasted token but which also + * offer a client-credentials grant, selected by `authMethod: 'client_credentials'`. + */ +const CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS: Record = { + ...TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, +} + +function getClientCredentialsModeFields( + providerId: string | null | undefined, + authMethod: unknown +): readonly ServiceAccountFieldId[] | undefined { + return authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD && + providerId && + Object.hasOwn(CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS, providerId) + ? CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS[providerId] + : undefined +} + /** * The required create-body fields a submission is missing: none when it - * completes the provider's keyless alternative, otherwise the provider's own - * required fields that are empty. + * completes the provider's keyless alternative, the client-credentials fields + * when it selects that mode, otherwise the provider's own required fields that + * are empty. */ export function getMissingServiceAccountFields( providerId: string | null | undefined, @@ -114,5 +138,8 @@ export function getMissingServiceAccountFields( ): ServiceAccountFieldId[] { const keyless = getKeylessServiceAccountFields(providerId) if (keyless?.every((field) => provided[field])) return [] - return getServiceAccountRequiredFields(providerId).filter((field) => !provided[field]) + const required = + getClientCredentialsModeFields(providerId, provided.authMethod) ?? + getServiceAccountRequiredFields(providerId) + return required.filter((field) => !provided[field]) } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 4cdd2f13815..ec8500c358e 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -16,6 +16,14 @@ const { })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: async (input: { + exchange: () => Promise<{ accessToken: string; expiresAt: Date }> + }) => { + const exchanged = await input.exchange() + return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } + }, +})) const mockEncryptSecret = encryptionMockFns.mockEncryptSecret vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId })) vi.mock('@/lib/credentials/atlassian-service-account', () => ({ @@ -490,3 +498,98 @@ describe('verifyAndBuildServiceAccountSecret', () => { } }) }) + +describe('a token provider in client-credentials mode', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + }) + + function answerShopify() { + fetchMock.mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url.endsWith('/admin/oauth/access_token')) { + return Response.json({ access_token: 'minted-token', expires_in: 86399 }) + } + expect(request.headers.get('x-shopify-access-token')).toBe('minted-token') + return Response.json({ + data: { shop: { name: 'Acme', myshopifyDomain: 'acme.myshopify.com' } }, + }) + }) + } + + it('stores the client credentials, never a token, after proving the minted token', async () => { + answerShopify() + + const result = await verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'Acme.myshopify.com', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'acme.myshopify.com', + }) + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('minted-token') + expect(result.principal).toMatchObject({ kind: 'tenant', id: 'acme.myshopify.com' }) + }) + + it('keeps a pasted Shopify token on the token path when no auth method is sent', async () => { + fetchMock.mockResolvedValue( + Response.json({ data: { shop: { name: 'Acme', myshopifyDomain: 'acme.myshopify.com' } } }) + ) + + const result = await verifyAndBuildServiceAccountSecret('shopify-service-account', { + apiToken: 'shpat_pasted', + domain: 'acme.myshopify.com', + clientId: 'ignored', + clientSecret: 'ignored', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob.apiToken).toBe('shpat_pasted') + expect(blob.authMethod).toBeUndefined() + expect(blob.clientSecret).toBeUndefined() + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('requires the client ID, secret, and store domain without calling Shopify', async () => { + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + domain: 'acme.myshopify.com', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps the token path for a provider without a client-credentials mode', async () => { + await expect( + verifyAndBuildServiceAccountSecret('hubspot-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + }) + ).rejects.toThrow('apiToken is required') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index dcd62f7ea20..69679d3072f 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -48,15 +48,20 @@ import { SnowflakeCredentialError, verifyAndEncryptSnowflakeKeylessCredential, } from '@/lib/credentials/snowflake-service-account.server' +import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + type TokenServiceAccountDescriptor, } from '@/lib/credentials/token-service-accounts/descriptors' import { getTokenServiceAccountValidator, + type TokenServiceAccountClientCredentialBlob, type TokenServiceAccountSecretBlob, + type TokenServiceAccountValidator, } from '@/lib/credentials/token-service-accounts/server' import { KeylessCredentialRefusedError, @@ -471,6 +476,12 @@ async function buildTokenServiceAccountSecret( `No validator registered for service-account provider ${providerId}` ) } + if ( + descriptor.clientCredentials && + fields.authMethod?.trim() === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + ) { + return buildTokenClientCredentialSecret(descriptor, validator, fields) + } const apiToken = fields.apiToken?.trim() const domain = fields.domain?.trim() const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') @@ -504,6 +515,62 @@ async function buildTokenServiceAccountSecret( } } +/** + * Builds a token provider's client-credentials secret: mints a token with the submitted client + * ID and secret, proves it with the provider's own token check (the same request the pasted-token + * mode verifies with), and stores the client credentials instead of a token so execution re-mints. + */ +async function buildTokenClientCredentialSecret( + descriptor: TokenServiceAccountDescriptor, + validator: TokenServiceAccountValidator, + fields: ServiceAccountSecretFields +): Promise { + const { providerId } = descriptor + const grant = getTokenClientCredentialGrant(providerId) + if (!grant) { + throw new ServiceAccountSecretError( + `No client-credentials grant registered for service-account provider ${providerId}` + ) + } + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const domain = fields.domain?.trim() + const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') + if (!clientId || !clientSecret || (requiresDomain && !domain)) { + throw new ServiceAccountSecretError( + `clientId, clientSecret${requiresDomain ? ', and domain are' : ' are'} required for ${descriptor.serviceLabel} client credentials` + ) + } + const minted = await grant.mint({ + clientId, + clientSecret, + ...(requiresDomain ? { domain } : {}), + }) + const validation = await validator({ + apiToken: minted.accessToken, + ...(requiresDomain ? { domain } : {}), + authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + }) + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) + const blob: TokenServiceAccountClientCredentialBlob = { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId, + authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + clientId, + clientSecret, + ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), + metadata: { ...validation.storedMetadata, ...principalMetadata }, + } + const { encrypted } = await encryptSecret(JSON.stringify(blob)) + return { + providerId, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, + } +} + /** * Builds a Snowflake secret. A JSON configuration is the keyless mode (no * stored secret); otherwise the pasted programmatic access token goes through diff --git a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts new file mode 100644 index 00000000000..670a98024ce --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts @@ -0,0 +1,56 @@ +import { + isTokenServiceAccountProviderId, + SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, + type TokenServiceAccountProviderId, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' + +/** What a token provider's client-credentials grant needs to mint an access token. */ +export interface TokenClientCredentialFields { + clientId: string + clientSecret: string + /** The normalized account host, for providers whose token endpoint is per account (Shopify). */ + domain?: string +} + +export interface TokenClientCredentialMint { + accessToken: string + expiresInSeconds: number +} + +export type TokenClientCredentialMinter = ( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +) => Promise + +export interface TokenClientCredentialGrant { + mint: TokenClientCredentialMinter + /** + * The minter keeps one token per provider application across every worker itself, so the + * resolver must not cache its tokens per instance. Set for providers that do not document + * whether a new token leaves earlier ones valid. + */ + sharedToken: boolean +} + +/** + * Client-credentials grants offered beside the pasted token, for token providers whose + * descriptor declares `clientCredentials`. The pasted-token mode is unchanged; a credential uses + * the grant only when it was saved with `authMethod: 'client_credentials'`. + */ +const TOKEN_CLIENT_CREDENTIAL_GRANTS: Partial< + Record +> = { + [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: { + mint: mintShopifyClientCredentialsToken, + sharedToken: true, + }, +} + +export function getTokenClientCredentialGrant( + providerId: string +): TokenClientCredentialGrant | undefined { + return isTokenServiceAccountProviderId(providerId) + ? TOKEN_CLIENT_CREDENTIAL_GRANTS[providerId] + : undefined +} diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ebd5a56b5d7..c220cdb467f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -67,8 +67,37 @@ export interface TokenServiceAccountDescriptor { * token route surfaces this so tool header builders can switch schemes. */ authStyle?: 'bearer' | 'x-api-token' + /** + * A client-credentials grant the provider offers beside the pasted token. A credential saved + * with `authMethod: 'client_credentials'` stores the client ID and secret (and `domain`, when + * the descriptor has one) and mints a short-lived token at execution time; a credential saved + * without it keeps the pasted-token mode unchanged. + */ + clientCredentials?: TokenServiceAccountClientCredentials +} + +/** Copy for a token provider's client-credentials mode in the connect modal. */ +export interface TokenServiceAccountClientCredentials { + /** Authentication dropdown label for the client-credentials mode. */ + label: string + /** Authentication dropdown label for the existing pasted-token mode. */ + tokenLabel: string + /** Guidance shown under the client ID field. */ + helpText: string + /** Replaces the generic `invalid_credentials` copy for this mode. */ + invalidCredentialsHelp: string + /** Copy for `grant_not_permitted`, when the provider can refuse the grant for one account. */ + notPermittedHelp?: string + /** + * Preselects this mode for a new connection, for providers whose current setup no longer + * issues a pasted token. A reconnect always opens on the pasted-token mode. + */ + preferred?: boolean } +/** `authMethod` value selecting a token provider's client-credentials mode. */ +export const TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const + export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const @@ -506,9 +535,20 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', helpText: - 'The token is revealed once, is bound to a single store, and does not expire. Dev Dashboard apps issue tokens through OAuth rather than a UI reveal.', + 'Only custom apps created in the Shopify admin reveal this token. For an app created in the Dev Dashboard, choose Client credentials instead.', invalidCredentialsHelp: 'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.', + clientCredentials: { + preferred: true, + label: 'Client credentials (Dev Dashboard app)', + tokenLabel: 'Admin API access token (shpat_)', + helpText: + 'Use the client ID and secret from the Dev Dashboard app. The app must be installed on this store, and the app and store must belong to the same Shopify organization.', + invalidCredentialsHelp: + 'Shopify rejected these client credentials. Check that the client ID and secret belong to a Dev Dashboard app that is installed on this exact store domain.', + notPermittedHelp: + 'Shopify does not allow client credentials for this store. Client credentials only work when the app and the store belong to the same Shopify organization, so check that this store is part of the organization that owns the app.', + }, }, [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2342,6 +2382,26 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< }, } +/** + * Required contract fields per token provider in client-credentials mode: the client ID and + * secret replace the token, and every other descriptor field (a store domain) stays required. + */ +export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< + string, + ReadonlyArray<'clientId' | 'clientSecret' | Exclude> +> = Object.fromEntries( + Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS) + .filter((descriptor) => descriptor.clientCredentials) + .map((descriptor) => [ + descriptor.providerId, + [ + 'clientId', + 'clientSecret', + ...descriptor.fields.flatMap((field) => (field.id === 'apiToken' ? [] : [field.id])), + ], + ]) +) + /** * Required contract fields per token service-account provider, consumed by the * `createCredentialBodySchema` superRefine so validation errors name the exact @@ -2379,14 +2439,23 @@ export function getTokenServiceAccountDescriptor( export function getTokenServiceAccountErrorMessage( descriptor: TokenServiceAccountDescriptor, code: string | undefined, - fallbackMessage = "We couldn't add this credential. Try again in a moment." + fallbackMessage = "We couldn't add this credential. Try again in a moment.", + authMethod?: string ): string { + const clientCredentials = + authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD ? descriptor.clientCredentials : undefined switch (code) { case 'invalid_credentials': return ( + clientCredentials?.invalidCredentialsHelp ?? descriptor.invalidCredentialsHelp ?? `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` ) + case 'grant_not_permitted': + return ( + clientCredentials?.notPermittedHelp ?? + `${descriptor.serviceLabel} does not allow these client credentials for this account.` + ) case 'site_not_found': return "We couldn't find an account at that domain. Check the spelling and try again." case 'host_not_allowed': diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 79e4d985f33..3682e4bf4e8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -26,6 +26,12 @@ export type TokenServiceAccountValidationCode = * asked for a Google Workspace service or to impersonate a Google Workspace user (domain-wide delegation). */ | 'unsupported_use' + /** + * The provider refused this grant for the account the credential names, although the client + * itself may be valid: Shopify answers `shop_not_permitted` when the app and the store are not + * in the same Shopify organization. + */ + | 'grant_not_permitted' export class TokenServiceAccountValidationError extends Error { constructor( diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts new file mode 100644 index 00000000000..71272a0f471 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts @@ -0,0 +1,130 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' + +const FIELDS = { + clientId: 'shopify-client', + clientSecret: 'shopify-secret', + domain: 'https://Acme-Store.myshopify.com/', +} + +const mockFetch = vi.fn() + +describe('Shopify client credentials grant', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('exchanges the client credentials at the store token endpoint without a scope', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://acme-store.myshopify.com/admin/oauth/access_token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'shopify-client'], + ['client_secret', 'shopify-secret'], + ]) + return jsonResponse({ access_token: 'shop-token', scope: 'read_products', expires_in: 86399 }) + }) + + const minted = await mintShopifyClientCredentialsToken(FIELDS) + + expect(minted.accessToken).toBe('shop-token') + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'shopify', + application: ['acme-store.myshopify.com', 'shopify-client'], + }) + ) + }) + + it.each(['localhost', 'acme.myshopify.com.evil.example', '169.254.169.254', 'acme.example.com'])( + 'refuses %s before any request', + async (domain) => { + await expect(mintShopifyClientCredentialsToken({ ...FIELDS, domain })).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(coordinated).not.toHaveBeenCalled() + } + ) + + it('reports shop_not_permitted as a refused grant rather than a bad secret', async () => { + mockFetch.mockResolvedValue( + jsonResponse( + { + error: 'shop_not_permitted', + error_description: 'Client credentials cannot be performed on this shop.', + }, + { status: 400 } + ) + ) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'grant_not_permitted', + }) + }) + + it.each([ + [401, 'invalid_credentials'], + [400, 'invalid_credentials'], + [404, 'site_not_found'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'invalid_client' }, { status })) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it.each([ + ['no access token', { expires_in: 86399 }], + ['a blank access token', { access_token: ' ', expires_in: 86399 }], + ['no lifetime', { access_token: 'shop-token' }], + ['a non-positive lifetime', { access_token: 'shop-token', expires_in: 0 }], + ])('refuses a token response with %s', async (_label, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('never keeps a token longer than the documented 24 hours', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'shop-token', expires_in: 9_999_999 })) + + const minted = await mintShopifyClientCredentialsToken(FIELDS) + + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) + }) + + it('never sends a request without a client secret', async () => { + await expect( + mintShopifyClientCredentialsToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts new file mode 100644 index 00000000000..3ab8e2e449f --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts @@ -0,0 +1,134 @@ +import { isRecordLike } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { + fetchProvider, + providerFailureCode, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { + normalizeShopifyDomain, + SHOPIFY_HOST_REGEX, +} from '@/lib/credentials/token-service-accounts/validators/shopify' + +const STEP = 'shopify_token_mint' +const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +/** Shopify documents a fixed 24-hour lifetime (`expires_in` is always 86399). */ +const MAX_TOKEN_LIFETIME_SECONDS = 86_400 + +async function exchangeShopifyToken( + shop: string, + clientId: string, + clientSecret: string, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresAt: Date }> { + const startedAt = Date.now() + const response = await fetchProvider( + `https://${shop}/admin/oauth/access_token`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + // Shopify ignores `scope` on this grant; the token carries every scope the store granted. + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }).toString(), + redirect: 'error', + cache: 'no-store', + signal, + }, + STEP, + { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } + ) + if (!response.ok) { + const body = await readProviderErrorSnippet(response) + if (/shop_not_permitted/i.test(body)) { + throw new TokenServiceAccountValidationError('grant_not_permitted', response.status, { + step: STEP, + domain: shop, + reason: 'app and store are not in the same Shopify organization', + }) + } + throw new TokenServiceAccountValidationError( + response.status === 404 ? 'site_not_found' : providerFailureCode(response.status), + response.status, + { step: STEP, domain: shop, reason: 'provider rejected token exchange' } + ) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: MAX_TOKEN_RESPONSE_BYTES, + label: 'Shopify token response', + signal, + }) + } catch { + signal?.throwIfAborted() + payload = undefined + } + if ( + !isRecordLike(payload) || + typeof payload.access_token !== 'string' || + !payload.access_token.trim() || + typeof payload.expires_in !== 'number' || + !Number.isSafeInteger(payload.expires_in) || + payload.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid token response', + }) + } + const lifetimeSeconds = Math.min(payload.expires_in, MAX_TOKEN_LIFETIME_SECONDS) + return { + accessToken: payload.access_token, + expiresAt: new Date(startedAt + lifetimeSeconds * 1000), + } +} + +/** + * Mints an Admin API token for a Dev Dashboard app installed on a store in the same Shopify + * organization, through the client-credentials grant. Shopify documents a 24-hour lifetime but + * not whether a new token retires earlier ones, so every saved credential for one app and store + * shares a single coordinated token instead of minting per worker. + * + * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant + */ +export async function mintShopifyClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const shop = normalizeShopifyDomain(fields.domain ?? '') + if (!SHOPIFY_HOST_REGEX.test(shop)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + domain: shop, + reason: 'host is not a Shopify store domain (expected *.myshopify.com)', + }) + } + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'shopify', + application: [shop, clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => exchangeShopifyToken(shop, clientId, fields.clientSecret, signal), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index e935ef4331c..e617a2b2fb2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' import { AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID, @@ -107,6 +108,7 @@ import { TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID, THRIVE_SERVICE_ACCOUNT_PROVIDER_ID, TINYBIRD_SERVICE_ACCOUNT_PROVIDER_ID, + TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, type TokenServiceAccountProviderId, TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, @@ -250,6 +252,12 @@ export interface TokenServiceAccountFields { apiToken: string domain?: string providerWorkspaceId?: string + /** + * `client_credentials` when `apiToken` was just minted from the provider's client-credentials + * grant rather than pasted, for checks whose identity differs between the two (a Linear app + * actor). Absent for a pasted token. + */ + authMethod?: typeof TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD } /** Result of a successful provider verification. */ @@ -435,17 +443,82 @@ export interface TokenServiceAccountSecretBlob { metadata?: Record } +/** + * Secret blob for a token provider's client-credentials mode: the client ID and secret it mints + * with replace the pasted token. Only providers declaring `clientCredentials` write it. + */ +export interface TokenServiceAccountClientCredentialBlob { + type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE + providerId: string + authMethod: typeof TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + clientId: string + clientSecret: string + domain?: string + metadata?: Record +} + +/** + * Parses a pasted-token blob. A client-credentials blob has no token and is refused, so callers + * that only understand pasted tokens (Snowflake) fail closed on one. + */ export function parseTokenServiceAccountSecretBlob( decrypted: string, expectedProviderId: string ): TokenServiceAccountSecretBlob { - const parsed = JSON.parse(decrypted) as TokenServiceAccountSecretBlob + const parsed = parseTokenServiceAccountSecret(decrypted, expectedProviderId) + if ('authMethod' in parsed) { + throw new Error('Stored token service-account secret is malformed') + } + return parsed +} + +/** Parses either mode of a token provider's secret blob, checking it belongs to the provider. */ +export function parseTokenServiceAccountSecret( + decrypted: string, + expectedProviderId: string +): TokenServiceAccountSecretBlob | TokenServiceAccountClientCredentialBlob { + const malformed = new Error('Stored token service-account secret is malformed') + const parsed: unknown = JSON.parse(decrypted) if ( + !isRecordLike(parsed) || parsed.type !== TOKEN_SERVICE_ACCOUNT_SECRET_TYPE || - parsed.providerId !== expectedProviderId || - !parsed.apiToken + parsed.providerId !== expectedProviderId ) { - throw new Error('Stored token service-account secret is malformed') + throw malformed + } + const optional = { + ...(typeof parsed.domain === 'string' ? { domain: parsed.domain } : {}), + ...(isRecordLike(parsed.metadata) + ? { metadata: parsed.metadata as Record } + : {}), + } + if (parsed.authMethod === undefined) { + if (typeof parsed.apiToken !== 'string' || !parsed.apiToken) throw malformed + return { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: expectedProviderId, + apiToken: parsed.apiToken, + ...(typeof parsed.providerWorkspaceId === 'string' + ? { providerWorkspaceId: parsed.providerWorkspaceId } + : {}), + ...optional, + } + } + if ( + parsed.authMethod !== TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD || + typeof parsed.clientId !== 'string' || + !parsed.clientId || + typeof parsed.clientSecret !== 'string' || + !parsed.clientSecret + ) { + throw malformed + } + return { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: expectedProviderId, + authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: parsed.clientId, + clientSecret: parsed.clientSecret, + ...optional, } - return parsed } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 86cfae024e1..a407d957317 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -37,6 +37,7 @@ describe('validateShopifyServiceAccount', () => { 'X-Shopify-Access-Token': 'shpat_abc', }, body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }), + redirect: 'error', signal: expect.any(AbortSignal), } ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 54ff330897a..795ffb36082 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -16,7 +16,7 @@ import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' * host — exactly one label before `myshopify.com`, so lookalike hosts such as * `sub.myshopify.com.evil.com` are rejected before any outbound fetch. */ -const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ +export const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ /** * Every selected field must stay scope-free. `hasShopifyAuthError` treats an @@ -57,7 +57,7 @@ function hasShopifyAuthError(errors: unknown): boolean { } /** Strips the protocol and trailing slashes and lowercases the store domain. */ -function normalizeShopifyDomain(rawDomain: string): string { +export function normalizeShopifyDomain(rawDomain: string): string { return rawDomain .trim() .replace(/^https?:\/\//i, '') @@ -95,6 +95,7 @@ export async function validateShopifyServiceAccount( 'X-Shopify-Access-Token': fields.apiToken, }, body: JSON.stringify({ query: SHOP_QUERY }), + redirect: 'error', }, 'shop_query', { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 4d04f3b1f2a..dc2a0b41bde 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -30,6 +30,14 @@ vi.mock('@/lib/concurrency/leader-lock', () => ({ })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: async (input: { + exchange: () => Promise<{ accessToken: string; expiresAt: Date }> + }) => { + const exchanged = await input.exchange() + return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } + }, +})) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ isWorkloadIdentityConfigured: async () => true, mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `sim-oidc|${subject}`, @@ -1353,3 +1361,86 @@ describe('OCI service-account resolver', () => { ).resolves.toEqual({ accessToken: 'credential-authoritative' }) }) }) + +describe('token provider credentials saved in client-credentials mode', () => { + const fetchMock = vi.fn() + const shopifyBlob = { + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'acme.myshopify.com', + } + + beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + }) + + function storeBlob(credentialId: string, blob: Record, reads = 2) { + for (let read = 0; read < reads; read++) { + queueTableRows(credential, [{ encryptedServiceAccountKey: `enc-${credentialId}` }]) + } + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + } + + it('mints a Shopify token with the stored client credentials and returns the store domain', async () => { + storeBlob('shopify-cc-1', shopifyBlob) + fetchMock.mockResolvedValue(Response.json({ access_token: 'minted', expires_in: 86399 })) + + await expect( + resolveServiceAccountToken('shopify-cc-1', 'shopify-service-account') + ).resolves.toEqual({ accessToken: 'minted', domain: 'acme.myshopify.com' }) + const request = new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]) + expect(request.url).toBe('https://acme.myshopify.com/admin/oauth/access_token') + }) + + it('leaves a coordinated Shopify token to the coordinator instead of caching it per instance', async () => { + storeBlob('shopify-cc-2', shopifyBlob, 4) + fetchMock.mockImplementation(async () => + Response.json({ access_token: `minted-${fetchMock.mock.calls.length}`, expires_in: 86399 }) + ) + + await resolveServiceAccountToken('shopify-cc-2', 'shopify-service-account') + await expect( + resolveServiceAccountToken('shopify-cc-2', 'shopify-service-account') + ).resolves.toMatchObject({ accessToken: 'minted-2' }) + }) + + it('still returns a pasted Shopify token as is', async () => { + storeBlob( + 'shopify-pasted', + { + type: 'token_service_account', + providerId: 'shopify-service-account', + apiToken: 'shpat_pasted', + domain: 'acme.myshopify.com', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('shopify-pasted', 'shopify-service-account') + ).resolves.toEqual({ + accessToken: 'shpat_pasted', + domain: 'acme.myshopify.com', + providerWorkspaceId: undefined, + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refuses a client-credentials blob stored under another provider', async () => { + storeBlob('shopify-wrong', { ...shopifyBlob, providerId: 'hubspot-service-account' }, 1) + + await expect( + resolveServiceAccountToken('shopify-wrong', 'shopify-service-account') + ).rejects.toThrow('malformed') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index ec263abf4d6..6a17ad2f695 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -19,13 +19,15 @@ import { parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' +import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/token-service-accounts/descriptors' import { - parseTokenServiceAccountSecretBlob, + parseTokenServiceAccountSecret, + type TokenServiceAccountClientCredentialBlob, type TokenServiceAccountSecretBlob, } from '@/lib/credentials/token-service-accounts/server' import { CLEARED_REFRESH_REVOCATION, CredentialRevokedError } from '@/lib/oauth/credential-revoked' @@ -566,13 +568,14 @@ export interface ServiceAccountTokenResult { /** * Loads and parses the decrypted secret blob for a token service-account - * credential (pasted long-lived provider token). Throws if the credential is - * missing or the blob doesn't belong to the expected provider. + * credential: a pasted long-lived provider token, or the client ID and secret + * of a provider's client-credentials mode. Throws if the credential is missing + * or the blob doesn't belong to the expected provider. */ async function getTokenServiceAccountSecret( credentialId: string, providerId: string -): Promise { +): Promise { const [credentialRow] = await db .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) .from(credential) @@ -584,11 +587,12 @@ async function getTokenServiceAccountSecret( } const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) - return parseTokenServiceAccountSecretBlob(decrypted, providerId) + return parseTokenServiceAccountSecret(decrypted, providerId) } interface CachedClientCredentialToken { - accessToken: string + /** The resolved token with the destination fields that travel with it. */ + token: ServiceAccountTokenResult expiresAtMs: number /** * Fingerprint of the encrypted secret the token was minted from (see @@ -597,10 +601,12 @@ interface CachedClientCredentialToken { * the cached token belongs to the old app and must be re-minted. */ secretFingerprint: string - /** Salesforce or NetSuite — the provider origin returned alongside the minted token. */ - instanceUrl?: string - /** Zoho Desk only — the Desk REST base derived from the token's api_domain. */ - apiDomain?: string +} + +/** A token minted from a stored secret, with how long it stays valid. */ +interface MintedServiceAccountToken { + token: ServiceAccountTokenResult + expiresInSeconds: number } interface FailedClientCredentialMint { @@ -662,17 +668,19 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string { } /** - * Resolves a client-credential service-account credential to a short-lived - * access token: decrypts the stored credential material (client id + secret, - * or the private key and run-as username for key-based grants) and mints via - * the provider's registered minter (skipping the connect-time identity - * lookup), read-through the per-instance cache. Wrapped in `coalesceLocally` - * so concurrent block executions on one instance share a single mint. + * Resolves a stored secret that mints short-lived tokens, read-through the + * per-instance cache: re-reads the credential row, serves a cached token minted + * from the same ciphertext while enough of it remains, replays a recent mint + * failure, and otherwise decrypts and mints. Wrapped in `coalesceLocally` so + * concurrent block executions on one instance share a single mint. + * `sharedToken` providers coordinate one token per application themselves, so + * their tokens are never cached here. */ -async function resolveClientCredentialAccountToken( +async function resolveMintedServiceAccountToken( credentialId: string, - providerId: string, - options?: CredentialTokenResolutionOptions + mint: (decrypted: string) => Promise, + options: CredentialTokenResolutionOptions | undefined, + sharedToken: boolean ): Promise { const signal = options?.signal signal?.throwIfAborted() @@ -694,18 +702,13 @@ async function resolveClientCredentialAccountToken( } const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey) - const usesSharedToken = providerId === VANTA_SERVICE_ACCOUNT_PROVIDER_ID - const cached = usesSharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) + const cached = sharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) if ( cached && cached.secretFingerprint === secretFingerprint && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS ) { - return { - accessToken: cached.accessToken, - instanceUrl: cached.instanceUrl, - apiDomain: cached.apiDomain, - } + return { ...cached.token } } const failed = clientCredentialMintFailureCache.get(cacheIdentity) @@ -720,12 +723,47 @@ async function resolveClientCredentialAccountToken( try { const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + const minted = await mint(decrypted) + if (!sharedToken) + clientCredentialTokenCache.set(cacheIdentity, { + token: minted.token, + expiresAtMs: Date.now() + minted.expiresInSeconds * 1000, + secretFingerprint, + }) + return { ...minted.token } + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + clientCredentialMintFailureCache.set(cacheIdentity, { + error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, + secretFingerprint, + expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, + }) + } + throw error + } + }) + return waitWithAbort(resolution, signal) +} + +/** + * Resolves a client-credential service-account credential to a short-lived + * access token: decrypts the stored credential material (client id + secret, + * or the private key and run-as username for key-based grants) and mints via + * the provider's registered minter, skipping the connect-time identity lookup. + */ +function resolveClientCredentialAccountToken( + credentialId: string, + providerId: string, + options?: CredentialTokenResolutionOptions +): Promise { + return resolveMintedServiceAccountToken( + credentialId, + async (decrypted) => { const blob = parseClientCredentialAccountSecretBlob(decrypted, providerId) const minter = getClientCredentialAccountMinter(providerId) if (!minter) { throw new Error(`No minter registered for service-account provider ${providerId}`) } - const mint = await minter( { clientId: blob.clientId, @@ -740,31 +778,58 @@ async function resolveClientCredentialAccountToken( }, { skipIdentity: true } ) - if (!usesSharedToken) - clientCredentialTokenCache.set(cacheIdentity, { + return { + token: { accessToken: mint.accessToken, - expiresAtMs: Date.now() + mint.expiresInSeconds * 1000, - secretFingerprint, instanceUrl: mint.instanceUrl, apiDomain: mint.apiDomain, - }) - return { - accessToken: mint.accessToken, - instanceUrl: mint.instanceUrl, - apiDomain: mint.apiDomain, + }, + expiresInSeconds: mint.expiresInSeconds, } - } catch (error) { - if (!(error instanceof Error && error.name === 'AbortError')) { - clientCredentialMintFailureCache.set(cacheIdentity, { - error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, - secretFingerprint, - expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, - }) + }, + options, + providerId === VANTA_SERVICE_ACCOUNT_PROVIDER_ID + ) +} + +/** + * Resolves a token provider credential saved in client-credentials mode + * (Shopify Dev Dashboard app, Linear OAuth app): mints with the stored client + * ID and secret instead of returning a pasted token. The stored store domain + * travels with the token exactly as it does for a pasted one. + */ +function resolveTokenClientCredentialToken( + credentialId: string, + providerId: string, + options?: CredentialTokenResolutionOptions +): Promise { + const grant = getTokenClientCredentialGrant(providerId) + if (!grant) { + return Promise.reject( + new Error(`No client-credentials grant registered for service-account provider ${providerId}`) + ) + } + return resolveMintedServiceAccountToken( + credentialId, + async (decrypted) => { + const blob = parseTokenServiceAccountSecret(decrypted, providerId) + if (!('authMethod' in blob)) { + throw new Error('Stored token service-account secret changed mode') } - throw error - } - }) - return waitWithAbort(resolution, signal) + // The mint is shared by every coalesced caller, so no one caller's signal may cancel it. + const minted = await grant.mint({ + clientId: blob.clientId, + clientSecret: blob.clientSecret, + domain: blob.domain, + }) + return { + token: { accessToken: minted.accessToken, domain: blob.domain }, + expiresInSeconds: minted.expiresInSeconds, + } + }, + options, + grant.sharedToken + ) } interface ServiceAccountTokenOptions extends CredentialTokenResolutionOptions { @@ -888,6 +953,9 @@ export async function resolveServiceAccountToken( } if (providerId && isTokenServiceAccountProviderId(providerId)) { const secret = await getTokenServiceAccountSecret(credentialId, providerId) + if ('authMethod' in secret) { + return resolveTokenClientCredentialToken(credentialId, providerId, options) + } const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle return { accessToken: secret.apiToken, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 60fa5bb0883..d7165c8af40 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -493,6 +493,15 @@ export async function resolveCredentialToken( 'This credential cannot be used here. Keyless Google Cloud credentials work only with Google Cloud tools such as BigQuery and cannot impersonate a Google Workspace user; use a JSON key credential instead', } } + if (error.code === 'grant_not_permitted') { + return { + ok: false, + status: 400, + code: error.code, + error: + 'The provider does not allow this app’s client credentials for the connected account — check the app’s installation and organization, then reconnect the credential', + } + } if (error.code === 'account_not_found') { return { ok: false, From 395f4c0c2a8dacb3309ee6b541182537481e8da3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:38:59 -0800 Subject: [PATCH 21/83] fix(credentials): keep keyless off when a pinned workload identity issuer has an unserved path Sim serves workload identity discovery and keys only under /oidc/workload, and clouds fetch /.well-known/openid-configuration, so a WORKLOAD_IDENTITY_ISSUER with any other path advertised keyless credentials that no cloud could verify. A pinned issuer must now have the /oidc/workload path (its host may differ), or keyless stays off with a log line naming the requirement. The default issuer derived from the app URL is unchanged, and the self-hosting and AWS credential docs state the requirement. --- .../docs/integrations/aws-credentials.mdx | 2 +- .../self-hosting/environment-variables.mdx | 2 +- .../workload-identity/issuer.test.ts | 23 ++++++++++++++++--- .../credentials/workload-identity/issuer.ts | 20 +++++++++++----- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index 65308e11f36..166ed96ba85 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -65,7 +65,7 @@ To rotate a key, create the new key in AWS, reconnect the credential with it, co Keyless roles need two things on a self-hosted deployment: - `WORKLOAD_IDENTITY_SIGNING_KEY`: a PKCS#8 PEM RSA private key that signs Sim's tokens. -- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https` or names a port, query, or fragment, because AWS can't use it. +- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. A pinned issuer may use another host that reaches Sim, but its path must be `/oidc/workload`, because Sim serves `/.well-known/openid-configuration` and `/jwks` only under that path. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https`, names a port, query, or fragment, or is pinned to another path, because AWS can't use it. Generate a key with `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`. diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 9261787a85d..2c816ddeeaa 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -167,7 +167,7 @@ Saved AWS, Databricks, Snowflake, Microsoft app, and Google Cloud credentials ca | Variable | Description | | --- | --- | | `WORKLOAD_IDENTITY_SIGNING_KEY` | PKCS#8 PEM RSA private key that signs Sim's workload-identity tokens: `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048` | -| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. Never change it once trust policies reference it. | +| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. A pinned issuer may use another host that reaches Sim, but its path must be `/oidc/workload`, where Sim serves discovery and keys; any other path keeps keyless off. Never change it once trust policies reference it. | | `WORKLOAD_IDENTITY_PREVIOUS_KEY` | A second published key (private or public PEM) that never signs: the incoming key before a rotation, or the retiring key after it | See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for setup and the rotation steps. diff --git a/apps/sim/lib/credentials/workload-identity/issuer.test.ts b/apps/sim/lib/credentials/workload-identity/issuer.test.ts index 88da9a3419a..478453ea985 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.test.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { generateKeyPairSync } from 'node:crypto' +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/core/config/env' @@ -147,6 +148,21 @@ describe('workload identity issuer', () => { expect((await workloadIdentityDiscoveryResponse()).status).toBe(404) }) + it.each([ + 'https://sim.example.com', + 'https://oidc.example.com/workload', + 'https://sim.example.com/oidc/workload/v2', + 'https://sim.example.com/OIDC/workload', + ])('stays off for an issuer whose path Sim does not serve discovery on (%s)', async (url) => { + setEnv('WORKLOAD_IDENTITY_ISSUER', url) + const issuer = await loadIssuer() + + expect(await issuer.isWorkloadIdentityConfigured()).toBe(false) + expect(getMockLogger('WorkloadIdentityIssuer').error).toHaveBeenCalledWith(expect.any(String), { + error: expect.objectContaining({ message: expect.stringContaining('/oidc/workload') }), + }) + }) + it('keeps signing with the current key when the retiring key is malformed', async () => { setEnv('WORKLOAD_IDENTITY_PREVIOUS_KEY', 'not a PEM key') const issuer = await loadIssuer() @@ -183,10 +199,11 @@ describe('workload identity issuer', () => { ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) }) - it('uses the pinned issuer URL when one is configured', async () => { - setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://oidc.example.com/workload/') + it('uses a pinned issuer on another host when it keeps the served path', async () => { + setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://oidc.example.com/oidc/workload/') const issuer = await loadIssuer() - expect(issuer.getWorkloadIdentityIssuer()).toBe('https://oidc.example.com/workload') + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(issuer.getWorkloadIdentityIssuer()).toBe('https://oidc.example.com/oidc/workload') }) }) diff --git a/apps/sim/lib/credentials/workload-identity/issuer.ts b/apps/sim/lib/credentials/workload-identity/issuer.ts index 135a36db6a1..9103502080c 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.ts @@ -73,7 +73,9 @@ async function loadPreviousPublicJwk(): Promise { * AWS and Databricks fetch discovery and keys from the issuer over https on the * default port, and discovery appends paths to it, so any other issuer (or one * with a query or fragment) can never be trusted; keyless stays off - * rather than advertising an issuer no relying party accepts. + * rather than advertising an issuer no relying party accepts. Sim serves + * discovery and keys only under {@link WORKLOAD_IDENTITY_PATH}, so a pinned + * issuer may name another host that reaches this app but must keep that path. */ function assertPublicIssuer(issuer: string): void { const url = new URL(issuer) @@ -82,12 +84,18 @@ function assertPublicIssuer(issuer: string): void { 'Workload identity issuer must be an https URL without a port, query, or fragment' ) } - if (!env.WORKLOAD_IDENTITY_ISSUER?.trim()) { - logger.warn( - 'Workload identity issuer follows the app URL; set WORKLOAD_IDENTITY_ISSUER so customer trust policies survive a URL change', - { issuer } - ) + if (env.WORKLOAD_IDENTITY_ISSUER?.trim()) { + if (url.pathname !== WORKLOAD_IDENTITY_PATH) { + throw new Error( + `WORKLOAD_IDENTITY_ISSUER must end in ${WORKLOAD_IDENTITY_PATH}, the path Sim serves discovery and keys on; only its host may differ` + ) + } + return } + logger.warn( + 'Workload identity issuer follows the app URL; set WORKLOAD_IDENTITY_ISSUER so customer trust policies survive a URL change', + { issuer } + ) } let signingMaterial: Promise | undefined From 72bbaa5baa79e13fcc47bcbc83c764f6e61a284a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:43:12 -0800 Subject: [PATCH 22/83] feat(credentials): saved CrowdStrike API client credential and credential-only block A CrowdStrike credential holds a Falcon API client id and secret, the cloud (from the existing Falcon cloud allowlist), and an optional MSSP member CID. The minter posts them to the cloud's /oauth2/token with redirects refused and returns the token with its expires_in lifetime (30 minutes when unusable) and the cloud, which the credential cache keys by credential id and secret fingerprint, so changing the cloud or secret re-mints. - clientCredentialOAuth: a policy for tools that mint their own token from inline client credentials. The credential fills the hidden accessToken and the region param; tokenReplacesParams names the inline secrets the token replaces, and the saved-credential contract check accepts exactly those. - crowdstrike_v2 is the credential-only successor; the legacy block keeps its inline client id, secret, and cloud and is hidden from the toolbar. - The operation uses a credential's token as is. Inline credentials now reuse a cached token (keyed by cloud, client id, and secret hash) until a minute before it expires, and a 401 drops it so the next run re-mints. - Client-credential mints can bind a domain (the cloud), carried through the token cache to tools. --- .../content/docs/integrations/crowdstrike.mdx | 6 +- apps/sim/blocks/blocks/crowdstrike.ts | 17 +++ apps/sim/blocks/registry-maps.ts | 7 +- .../lib/api/contracts/tools/crowdstrike.ts | 81 +++++++----- .../client-credential-accounts/descriptors.ts | 55 ++++++++ .../minters/crowdstrike.test.ts | 110 ++++++++++++++++ .../minters/crowdstrike.ts | 121 ++++++++++++++++++ .../client-credential-accounts/server.ts | 11 +- apps/sim/lib/internal/crowdstrike/client.ts | 51 +++++++- .../internal/crowdstrike/operations.test.ts | 80 ++++++++++++ .../lib/internal/crowdstrike/operations.ts | 34 ++++- .../credential-service-cancellation.test.ts | 16 +++ apps/sim/lib/oauth/credential-service.ts | 29 ++--- apps/sim/lib/oauth/oauth.ts | 19 +++ apps/sim/lib/oauth/token-resolution.test.ts | 1 + apps/sim/lib/oauth/types.ts | 2 + .../scripts/saved-credential-contract.test.ts | 72 +++++++++++ apps/sim/scripts/saved-credential-contract.ts | 13 +- apps/sim/serializer/saved-credentials.test.ts | 7 + .../tools/crowdstrike/create_indicators.ts | 9 ++ .../tools/crowdstrike/delete_indicators.ts | 9 ++ .../tools/crowdstrike/delete_rtr_session.ts | 9 ++ .../tools/crowdstrike/execute_rtr_command.ts | 9 ++ .../tools/crowdstrike/get_alert_details.ts | 9 ++ .../sim/tools/crowdstrike/get_case_details.ts | 9 ++ .../crowdstrike/get_host_group_details.ts | 9 ++ .../crowdstrike/get_indicator_details.ts | 9 ++ .../crowdstrike/get_rtr_command_status.ts | 9 ++ .../crowdstrike/get_sensor_aggregates.ts | 9 ++ .../tools/crowdstrike/get_sensor_details.ts | 9 ++ .../crowdstrike/get_vulnerability_details.ts | 9 ++ .../sim/tools/crowdstrike/init_rtr_session.ts | 9 ++ .../tools/crowdstrike/perform_host_action.ts | 9 ++ .../crowdstrike/perform_host_group_action.ts | 9 ++ apps/sim/tools/crowdstrike/query_alerts.ts | 9 ++ apps/sim/tools/crowdstrike/query_cases.ts | 9 ++ .../tools/crowdstrike/query_host_groups.ts | 9 ++ .../sim/tools/crowdstrike/query_indicators.ts | 9 ++ apps/sim/tools/crowdstrike/query_sensors.ts | 9 ++ .../crowdstrike/query_vulnerabilities.ts | 9 ++ apps/sim/tools/crowdstrike/types.ts | 17 ++- apps/sim/tools/crowdstrike/update_alerts.ts | 9 ++ .../tools/crowdstrike/update_indicators.ts | 9 ++ apps/sim/tools/shared/saved-credentials.ts | 36 ++++++ apps/sim/tools/types.ts | 7 + 45 files changed, 932 insertions(+), 67 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts diff --git a/apps/docs/content/docs/integrations/crowdstrike.mdx b/apps/docs/content/docs/integrations/crowdstrike.mdx index d009fb81689..62ea3b537fc 100644 --- a/apps/docs/content/docs/integrations/crowdstrike.mdx +++ b/apps/docs/content/docs/integrations/crowdstrike.mdx @@ -10,7 +10,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -[CrowdStrike](https://www.crowdstrike.com/) is a cybersecurity platform providing endpoint protection, threat intelligence, and identity security through its Falcon suite. This integration authenticates with a Falcon API client ID and secret against a chosen cloud region and covers the Alerts, Hosts, Host Groups, IOC Management, Spotlight, Real Time Response, Case Management, and Identity Protection APIs. +[CrowdStrike](https://www.crowdstrike.com/) is a cybersecurity platform providing endpoint protection, threat intelligence, and identity security through its Falcon suite. This integration covers the Alerts, Hosts, Host Groups, IOC Management, Spotlight, Real Time Response, Case Management, and Identity Protection APIs. With this integration, you can: @@ -23,7 +23,9 @@ With this integration, you can: - **Read cases**: Search Case Management cases and read case details - **Query identity sensors**: Search Identity Protection sensors, fetch sensor details, and run aggregate queries -Each operation maps to a specific Falcon API scope — for example `Alerts: Read` and `Alerts: Write`, `Hosts: Write` for containment, `Host groups: Read`/`Write`, `IOC Management: Read`/`Write`, `Vulnerabilities: Read`, `Real time response: Read`, and `Cases: Read`. Containment and indicator deletion change live protection behavior, so scope the credential to only the operations your workflows need. +Connect a Falcon API client once as a CrowdStrike credential: create the API client in Falcon under **Support and resources > API clients and keys**, then add its client ID and secret and choose the cloud your console signs in to. Sim checks the credential by requesting a token when you save it, and requests a new one before each 30-minute Falcon token expires. An MSSP parent CID can enter a member CID so every call acts on that one child CID. Workflows built with the earlier CrowdStrike block keep the client ID, secret, and cloud they entered in the block. + +Each operation maps to a specific Falcon API scope — for example `Alerts: Read` and `Alerts: Write`, `Hosts: Write` for containment, `Host groups: Read`/`Write`, `IOC Management: Read`/`Write`, `Vulnerabilities: Read`, `Real time response: Read`, and `Cases: Read`. Containment and indicator deletion change live protection behavior, so scope the API client to only the operations your workflows need. Note that CrowdStrike decommissioned the legacy Detects API (September 30, 2025) and the CrowdScore Incidents API (March 9, 2026). This integration uses the current Alerts API and Case Management API in their place. {/* MANUAL-CONTENT-END */} diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts index 7ada5dac11c..0e713d6bec7 100644 --- a/apps/sim/blocks/blocks/crowdstrike.ts +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -5,6 +5,7 @@ import { parseOptionalBooleanInput, parseOptionalJsonInput, parseOptionalNumberInput, + withSavedCredential, } from '@/blocks/utils' /** @@ -93,6 +94,8 @@ const CLEARED_OPTIONAL_PARAMS: Record = Object.freeze({ export const CrowdStrikeBlock: BlockConfig = { type: 'crowdstrike', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'crowdstrike_v2' }, name: 'CrowdStrike', description: 'Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities', @@ -1296,3 +1299,17 @@ export const CrowdStrikeBlockMeta = { }, ], } as const satisfies BlockMeta + +export const CrowdStrikeV2Block: BlockConfig = { + ...CrowdStrikeBlock, + type: 'crowdstrike_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(CrowdStrikeBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'crowdstrike', + title: 'CrowdStrike Credential', + }), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 1e0b4959fb3..9c642272d1b 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -68,7 +68,11 @@ import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_de import { ConvexBlock, ConvexBlockMeta, ConvexV2Block } from '@/blocks/blocks/convex' import { CredentialBlock } from '@/blocks/blocks/credential' import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' -import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike' +import { + CrowdStrikeBlock, + CrowdStrikeBlockMeta, + CrowdStrikeV2Block, +} from '@/blocks/blocks/crowdstrike' import { CrunchbaseBlock, CrunchbaseBlockMeta, CrunchbaseV2Block } from '@/blocks/blocks/crunchbase' import { CursorBlock, CursorBlockMeta, CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' @@ -554,6 +558,7 @@ export const BLOCK_REGISTRY: Record = { credential: CredentialBlock, credential_group: CredentialGroupBlock, crowdstrike: CrowdStrikeBlock, + crowdstrike_v2: CrowdStrikeV2Block, crunchbase: CrunchbaseBlock, crunchbase_v2: CrunchbaseV2Block, cursor: CursorBlock, diff --git a/apps/sim/lib/api/contracts/tools/crowdstrike.ts b/apps/sim/lib/api/contracts/tools/crowdstrike.ts index 788983bd260..1a2d9e21cd2 100644 --- a/apps/sim/lib/api/contracts/tools/crowdstrike.ts +++ b/apps/sim/lib/api/contracts/tools/crowdstrike.ts @@ -5,10 +5,11 @@ import type { ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import type { - CrowdStrikeAggregateQuery, - CrowdStrikeSensorAggregateBucket, - CrowdStrikeSensorAggregateResult, +import { + CROWDSTRIKE_CLOUDS, + type CrowdStrikeAggregateQuery, + type CrowdStrikeSensorAggregateBucket, + type CrowdStrikeSensorAggregateResult, } from '@/tools/crowdstrike/types' const crowdstrikeNullableStringSchema = z.string().nullable() @@ -101,11 +102,15 @@ const crowdstrikeAggregatesResponseSchema = z.object({ }), }) -const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2'] as const - +/** + * Inline API client credentials, or the token a saved CrowdStrike credential + * minted for `cloud` in their place; {@link crowdstrikeQueryBodySchema} + * requires one or the other. + */ const baseRequestSchema = z.object({ - clientId: z.string().min(1, 'Client ID is required'), - clientSecret: z.string().min(1, 'Client Secret is required'), + clientId: z.string().min(1, 'Client ID is required').optional(), + clientSecret: z.string().min(1, 'Client Secret is required').optional(), + accessToken: z.string().min(1).optional(), cloud: z.enum(CROWDSTRIKE_CLOUDS), }) @@ -999,31 +1004,41 @@ const getCaseDetailsSchema = baseRequestSchema.extend({ caseIds: idsSchema(1000, 'case ID'), }) -export const crowdstrikeQueryBodySchema = z.discriminatedUnion('operation', [ - querySensorsSchema, - getSensorDetailsSchema, - getSensorAggregatesSchema, - queryAlertsSchema, - getAlertDetailsSchema, - updateAlertsSchema, - performHostActionSchema, - queryHostGroupsSchema, - getHostGroupDetailsSchema, - performHostGroupActionSchema, - queryIndicatorsSchema, - getIndicatorDetailsSchema, - createIndicatorsSchema, - updateIndicatorsSchema, - deleteIndicatorsSchema, - queryVulnerabilitiesSchema, - getVulnerabilityDetailsSchema, - initRtrSessionSchema, - executeRtrCommandSchema, - getRtrCommandStatusSchema, - deleteRtrSessionSchema, - queryCasesSchema, - getCaseDetailsSchema, -]) +export const crowdstrikeQueryBodySchema = z + .discriminatedUnion('operation', [ + querySensorsSchema, + getSensorDetailsSchema, + getSensorAggregatesSchema, + queryAlertsSchema, + getAlertDetailsSchema, + updateAlertsSchema, + performHostActionSchema, + queryHostGroupsSchema, + getHostGroupDetailsSchema, + performHostGroupActionSchema, + queryIndicatorsSchema, + getIndicatorDetailsSchema, + createIndicatorsSchema, + updateIndicatorsSchema, + deleteIndicatorsSchema, + queryVulnerabilitiesSchema, + getVulnerabilityDetailsSchema, + initRtrSessionSchema, + executeRtrCommandSchema, + getRtrCommandStatusSchema, + deleteRtrSessionSchema, + queryCasesSchema, + getCaseDetailsSchema, + ]) + .superRefine((body, ctx) => { + if (body.accessToken) return + if (!body.clientId) { + ctx.addIssue({ code: 'custom', path: ['clientId'], message: 'Client ID is required' }) + } + if (!body.clientSecret) { + ctx.addIssue({ code: 'custom', path: ['clientSecret'], message: 'Client Secret is required' }) + } + }) export const crowdstrikeQueryContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index a9d365612cd..068c5420174 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -10,6 +10,8 @@ * lives in `@/lib/credentials/client-credential-accounts/server`. */ +import { CROWDSTRIKE_CLOUDS, type CrowdStrikeCloud } from '@/tools/crowdstrike/types' + /** Discriminator stored inside every encrypted client-credential secret blob. */ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const @@ -136,6 +138,7 @@ export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-accoun export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const +export const CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID = 'crowdstrike-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -162,6 +165,7 @@ export type ClientCredentialAccountProviderId = | typeof HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID | typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID | typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -454,6 +458,16 @@ function buildDocuSignConsentUrl( return `https://${DOCUSIGN_ENVIRONMENTS[resolved].authHost}/oauth/auth?${params.toString()}` } +/** Falcon cloud names as the Falcon console shows them, keyed by the block's cloud ids. */ +const CROWDSTRIKE_CLOUD_LABELS: Record = { + 'us-1': 'US-1', + 'us-2': 'US-2', + 'us-3': 'US-3', + 'eu-1': 'EU-1', + 'us-gov-1': 'US-GOV-1', + 'us-gov-2': 'US-GOV-2', +} + export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor @@ -486,6 +500,47 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', }, + [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'CrowdStrike', + connectNoun: 'API client', + fields: [ + { + id: 'dataCenter', + label: 'Cloud', + placeholder: 'Select a cloud', + secret: false, + options: CROWDSTRIKE_CLOUDS.map((cloud) => ({ + value: cloud, + label: CROWDSTRIKE_CLOUD_LABELS[cloud], + })), + hint: 'The Falcon cloud your console signs in to.', + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Create the API client in Falcon under Support and resources > API clients and keys, with the scopes your workflows need.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + { + id: 'orgId', + label: 'Member CID', + placeholder: 'Only for an MSSP parent CID', + secret: false, + optional: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/crowdstrike', + helpText: + 'Leave blank unless the API client belongs to a parent CID and every call should act on one child CID.', + }, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'DocuSign', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts new file mode 100644 index 00000000000..0c80d05c9cf --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts @@ -0,0 +1,110 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' + +const FIELDS = { + clientId: 'falcon-client', + clientSecret: 'falcon-secret', + orgId: '', + dataCenter: 'eu-1', +} + +const mockFetch = vi.fn() + +describe('CrowdStrike API client token exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("posts the client credentials to the selected cloud's token endpoint and never follows a redirect", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + requests.push(request) + return jsonResponse({ access_token: 'falcon-access', expires_in: 1799 }) + }) + + const result = await mintCrowdStrikeServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://api.eu-1.crowdstrike.com/oauth2/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect(Object.fromEntries(new URLSearchParams(await request.text()))).toEqual({ + grant_type: 'client_credentials', + client_id: 'falcon-client', + client_secret: 'falcon-secret', + }) + expect(result).toMatchObject({ + accessToken: 'falcon-access', + expiresInSeconds: 1799, + domain: 'eu-1', + }) + expect(JSON.stringify(result.identity)).not.toContain('falcon-secret') + expect(JSON.stringify(result.identity)).not.toContain('falcon-access') + }) + + it('locks the token to the member CID an MSSP parent names', async () => { + let body = '' + mockFetch.mockImplementation(async (input, init) => { + body = await new Request(input, init).text() + return jsonResponse({ access_token: 'falcon-access', expires_in: 1799 }) + }) + + const result = await mintCrowdStrikeServiceAccountToken({ ...FIELDS, orgId: ' child-cid ' }) + + expect(new URLSearchParams(body).get('member_cid')).toBe('child-cid') + expect(result.identity?.auditMetadata).toMatchObject({ crowdstrikeMemberCid: 'child-cid' }) + }) + + it.each(['', 'us-4', 'api.crowdstrike.com', 'https://evil.example'])( + 'refuses the cloud %j before sending the secret anywhere', + async (dataCenter) => { + await expect( + mintCrowdStrikeServiceAccountToken({ ...FIELDS, dataCenter }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue( + jsonResponse({ errors: [{ code: status, message: 'access denied' }] }, { status }) + ) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('treats a redirect from the token endpoint as a provider failure, not a followed hop', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValue(jsonResponse({ expires_in: 1799 })) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('falls back to the documented 30-minute lifetime when expires_in is unusable', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'falcon-access', expires_in: 'soon' })) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).resolves.toMatchObject({ + expiresInSeconds: 1800, + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts new file mode 100644 index 00000000000..b477765e0e9 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts @@ -0,0 +1,121 @@ +import { toRecord } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + requireClientSecret, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import { + getCloudBaseUrl, + getTokenLifetimeSeconds, + isCrowdStrikeCloud, +} from '@/lib/internal/crowdstrike/client' + +const STEP = 'crowdstrike_token_mint' +const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +const MAX_MEMBER_CID_LENGTH = 128 +const FORBIDDEN_FIELD_TEXT = /[\u0000-\u001f\u007f\s]/ + +function invalidCredentials(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) +} + +/** + * Mints a Falcon API access token with the API client's id and secret: + * `POST {cloud API origin}/oauth2/token` (form fields `client_id`, + * `client_secret`, and, for an MSSP parent acting on one child, `member_cid`). + * The cloud comes only from the Falcon cloud allowlist, and the request never + * follows a redirect, so the secret reaches no other host. Tokens last 30 + * minutes; the credential cache re-mints before they lapse. + * + * Falcon exposes no identity for the API client in the token response, so the + * connect-time check is the mint itself. + * + * @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md + */ +export async function mintCrowdStrikeServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'CrowdStrike') + const cloud = fields.dataCenter?.trim() + if (!isCrowdStrikeCloud(cloud)) throw invalidCredentials('unknown Falcon cloud') + const memberCid = fields.orgId.trim() + if ( + memberCid && + (memberCid.length > MAX_MEMBER_CID_LENGTH || FORBIDDEN_FIELD_TEXT.test(memberCid)) + ) { + throw invalidCredentials('member CID is invalid') + } + + const baseUrl = getCloudBaseUrl(cloud) + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: clientSecret, + }) + if (memberCid) body.set('member_cid', memberCid) + + const response = await fetchProvider( + `${baseUrl}/oauth2/token`, + { + method: 'POST', + redirect: 'error', + signal: options?.signal, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }, + STEP + ) + await throwForProviderResponse(response, STEP, { cloud }) + + let token: Record + try { + token = toRecord( + await readResponseJsonWithLimit(response, { + maxBytes: MAX_TOKEN_RESPONSE_BYTES, + label: 'CrowdStrike token response', + signal: options?.signal, + }) + ) + } catch { + options?.signal?.throwIfAborted() + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid or oversized response', + }) + } + if (typeof token.access_token !== 'string' || !token.access_token.trim()) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'token response missing access_token', + }) + } + + return { + accessToken: token.access_token, + expiresInSeconds: getTokenLifetimeSeconds(token.expires_in), + domain: cloud, + identity: { + displayName: memberCid ? `CrowdStrike ${memberCid}` : `CrowdStrike ${cloud.toUpperCase()}`, + principal: memberCid ? tenantPrincipal(memberCid) : null, + auditMetadata: { + crowdstrikeClientId: fields.clientId, + crowdstrikeCloud: cloud, + ...(memberCid ? { crowdstrikeMemberCid: memberCid } : {}), + }, + storedMetadata: { cloud }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index ae13062cac4..8d651cf6b34 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -2,6 +2,7 @@ import { BOX_SERVICE_ACCOUNT_PROVIDER_ID, CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, type ClientCredentialAccountProviderId, + CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID, FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -20,6 +21,7 @@ import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' +import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' import { mintDocuSignServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/docusign' import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' @@ -48,7 +50,7 @@ export interface ClientCredentialAccountFields { /** * Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, * Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk - * origin, or an Oracle EPM environment URL). + * origin, an Oracle EPM environment URL, or a CrowdStrike member CID). */ orgId: string /** @@ -114,6 +116,12 @@ export interface ClientCredentialAccountMintResult { * {@link instanceUrl} because the two reach different tool params. */ apiDomain?: string + /** + * The region, site, or tenant the token is bound to (a CrowdStrike cloud), + * forwarded to tools as the credential's `domain`, which fills the tool param + * that picks the provider host. + */ + domain?: string /** Scopes granted to the app, when the provider reports them. */ grantedScopes?: string[] identity?: ClientCredentialAccountIdentity @@ -161,6 +169,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: mintGreenhouseServiceAccountToken, [HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID]: mintHelpScoutServiceAccountToken, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: mintDocuSignServiceAccountToken, + [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: mintCrowdStrikeServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index 1c4ed72526f..a14d31fbdd4 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -1,8 +1,10 @@ +import { createHash } from 'node:crypto' import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' +import { LRUCache } from 'lru-cache' import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' -import type { CrowdStrikeBaseParams, CrowdStrikeCloud } from '@/tools/crowdstrike/types' +import type { CrowdStrikeCloud, CrowdStrikeInlineCredentials } from '@/tools/crowdstrike/types' export type JsonRecord = Record @@ -19,6 +21,11 @@ export function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { return CLOUD_BASE_URLS[cloud] } +/** Narrows an untrusted value to one of the Falcon clouds in {@link CLOUD_BASE_URLS}. */ +export function isCrowdStrikeCloud(value: unknown): value is CrowdStrikeCloud { + return typeof value === 'string' && Object.hasOwn(CLOUD_BASE_URLS, value) +} + export function getStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -175,11 +182,47 @@ async function readFalconJson(response: Response): Promise { } } +/** Falcon's documented lifetime for an access token, used when `expires_in` is unusable. */ +const CROWDSTRIKE_TOKEN_LIFETIME_SECONDS = 30 * 60 +const INLINE_TOKEN_EXPIRY_BUFFER_MS = 60_000 + +/** A token response's `expires_in`, or Falcon's standard 30-minute lifetime when it is unusable. */ +export function getTokenLifetimeSeconds(expiresIn: unknown): number { + return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 + ? expiresIn + : CROWDSTRIKE_TOKEN_LIFETIME_SECONDS +} + +/** + * Tokens minted for inline (legacy block) credentials, keyed by cloud, client + * id, and a hash of the secret, so a rotated secret or another cloud never + * reuses one. Each entry lives until a minute before Falcon's `expires_in`. + */ +const inlineTokenCache = new LRUCache({ + max: 100, + ttlResolution: 0, + perf: { now: () => Date.now() }, +}) + +function inlineTokenCacheKey(params: CrowdStrikeInlineCredentials): string { + const secretHash = createHash('sha256').update(params.clientSecret).digest('hex') + return `${params.cloud}:${params.clientId}:${secretHash}` +} + +/** Drops a cached inline token Falcon rejected, so the next request mints a fresh one. */ +export function invalidateAccessToken(params: CrowdStrikeInlineCredentials): void { + inlineTokenCache.delete(inlineTokenCacheKey(params)) +} + export async function getAccessToken( - params: CrowdStrikeBaseParams, + params: CrowdStrikeInlineCredentials, signal?: AbortSignal ): Promise { signal?.throwIfAborted() + const cacheKey = inlineTokenCacheKey(params) + const cached = inlineTokenCache.get(cacheKey) + if (cached) return cached + const baseUrl = getCloudBaseUrl(params.cloud) const response = await fetch(`${baseUrl}/oauth2/token`, { method: 'POST', @@ -209,6 +252,10 @@ export async function getAccessToken( throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502) } + const ttl = Math.floor( + getTokenLifetimeSeconds(data.expires_in) * 1000 - INLINE_TOKEN_EXPIRY_BUFFER_MS + ) + if (ttl > 0) inlineTokenCache.set(cacheKey, data.access_token, { ttl }) return data.access_token } diff --git a/apps/sim/lib/internal/crowdstrike/operations.test.ts b/apps/sim/lib/internal/crowdstrike/operations.test.ts index 0429a724b29..d4894333e69 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.test.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.test.ts @@ -10,6 +10,7 @@ const fetchMock = vi.fn() describe('CrowdStrike operations', () => { beforeEach(() => { + fetchMock.mockReset() vi.stubGlobal('fetch', fetchMock) }) @@ -60,4 +61,83 @@ describe('CrowdStrike operations', () => { expect(call[1]).toMatchObject({ signal: controller.signal }) } }) + + it("sends a saved credential's token to its own cloud without minting one", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ resources: [] })) + + const result = await executeCrowdStrikeRequest({ + operation: 'crowdstrike_query_sensors', + accessToken: 'minted-token', + cloud: 'eu-1', + }) + + expect(result).toMatchObject({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(new URL(url).origin).toBe('https://api.eu-1.crowdstrike.com') + expect(new URL(url).pathname).not.toBe('/oauth2/token') + expect(init.headers).toMatchObject({ Authorization: 'Bearer minted-token' }) + }) + + it('refuses a request carrying neither inline credentials nor a token, before any call', async () => { + await expect( + executeCrowdStrikeRequest({ operation: 'crowdstrike_query_sensors', cloud: 'us-1' }) + ).rejects.toMatchObject({ status: 400 }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('reuses an inline token until Falcon rejects it, then mints a new one', async () => { + const body = { + operation: 'crowdstrike_query_sensors' as const, + clientId: 'cached-client', + clientSecret: 'cached-secret', + cloud: 'us-2' as const, + } + const tokenUrls: string[] = [] + const revokedTokens = new Set() + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + if (url.endsWith('/oauth2/token')) { + tokenUrls.push(url) + return jsonResponse({ access_token: `token-${tokenUrls.length}`, expires_in: 1799 }) + } + const auth = (init.headers as Record).Authorization + return revokedTokens.has(auth) + ? jsonResponse({ errors: [{ code: 401, message: 'access denied' }] }, 401) + : jsonResponse({ resources: [] }) + }) + + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + expect(tokenUrls).toEqual(['https://api.us-2.crowdstrike.com/oauth2/token']) + + revokedTokens.add('Bearer token-1') + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: false, status: 401 }) + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + expect(tokenUrls).toHaveLength(2) + }) + + it('never reuses an inline token for a different secret or cloud', async () => { + fetchMock.mockImplementation(async (url: string) => + url.endsWith('/oauth2/token') + ? jsonResponse({ access_token: 'token', expires_in: 1799 }) + : jsonResponse({ resources: [] }) + ) + const body = { + operation: 'crowdstrike_query_sensors' as const, + clientId: 'rotating-client', + clientSecret: 'secret-a', + cloud: 'us-1' as const, + } + + await executeCrowdStrikeRequest(body) + await executeCrowdStrikeRequest({ ...body, clientSecret: 'secret-b' }) + await executeCrowdStrikeRequest({ ...body, cloud: 'eu-1' }) + + const tokenCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/oauth2/token')) + expect(tokenCalls.map(([url]) => url)).toEqual([ + 'https://api.crowdstrike.com/oauth2/token', + 'https://api.crowdstrike.com/oauth2/token', + 'https://api.eu-1.crowdstrike.com/oauth2/token', + ]) + }) }) diff --git a/apps/sim/lib/internal/crowdstrike/operations.ts b/apps/sim/lib/internal/crowdstrike/operations.ts index d45d2d1f792..7caf4db4beb 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.ts @@ -5,6 +5,7 @@ import { truncate } from '@sim/utils/string' import type { CrowdstrikeQueryBody } from '@/lib/api/contracts/tools/crowdstrike' import { buildUrl, + CrowdStrikeAuthError, type CrowdStrikeCallResult, callCrowdStrike, getAccessToken, @@ -20,6 +21,7 @@ import { getSpotlightPagination, getStringArray, getStringResources, + invalidateAccessToken, } from '@/lib/internal/crowdstrike/client' import { normalizeAffectedEntity, @@ -31,6 +33,7 @@ import { } from '@/lib/internal/crowdstrike/normalizers' import type { CrowdStrikeActionParameter, + CrowdStrikeInlineCredentials, CrowdStrikeQuerySensorsParams, CrowdStrikeSensorAggregateBucket, CrowdStrikeSensorAggregateResult, @@ -1000,13 +1003,31 @@ export async function executeCrowdStrikeOperation( } } +function inlineCredentials(body: CrowdstrikeQueryBody): CrowdStrikeInlineCredentials { + if (!body.clientId || !body.clientSecret) { + throw new CrowdStrikeAuthError('CrowdStrike Client ID and Client Secret are required', 400) + } + return { clientId: body.clientId, clientSecret: body.clientSecret, cloud: body.cloud } +} + +/** + * Runs one CrowdStrike operation against `body.cloud`. A token minted from a + * saved credential (`accessToken`) is used as is; inline credentials mint a + * token, or reuse one cached for them, and a 401 drops that cached token so the + * next request mints again. + */ export async function executeCrowdStrikeRequest( body: CrowdstrikeQueryBody, signal?: AbortSignal ): Promise { signal?.throwIfAborted() const baseUrl = getCloudBaseUrl(body.cloud) - const accessToken = await getAccessToken(body, signal) + let accessToken = body.accessToken + let inline: CrowdStrikeInlineCredentials | undefined + if (!accessToken) { + inline = inlineCredentials(body) + accessToken = await getAccessToken(inline, signal) + } signal?.throwIfAborted() logger.info('CrowdStrike request', { @@ -1014,13 +1035,12 @@ export async function executeCrowdStrikeRequest( operation: body.operation, }) - if ( + const result = body.operation === 'crowdstrike_query_sensors' || body.operation === 'crowdstrike_get_sensor_details' || body.operation === 'crowdstrike_get_sensor_aggregates' - ) { - return executeSensorOperation(body, baseUrl, accessToken, signal) - } - - return executeCrowdStrikeOperation(body, baseUrl, accessToken, signal) + ? await executeSensorOperation(body, baseUrl, accessToken, signal) + : await executeCrowdStrikeOperation(body, baseUrl, accessToken, signal) + if (inline && !result.ok && result.status === 401) invalidateAccessToken(inline) + return result } diff --git a/apps/sim/lib/oauth/credential-service-cancellation.test.ts b/apps/sim/lib/oauth/credential-service-cancellation.test.ts index d50e3357828..b2adb169eb9 100644 --- a/apps/sim/lib/oauth/credential-service-cancellation.test.ts +++ b/apps/sim/lib/oauth/credential-service-cancellation.test.ts @@ -84,3 +84,19 @@ describe('client credential cancellation isolation', () => { ).resolves.toEqual({ accessToken: token.accessToken }) }) }) + +describe('client credential token cache', () => { + it('serves the region a token was minted for along with the cached token', async () => { + const mint = vi.fn() + mint.mockResolvedValue({ ...token, domain: 'eu-1' }) + vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint) + + for (let call = 0; call < 2; call++) { + queueTableRows(credential, [storedCredential]) + await expect( + resolveServiceAccountToken('ramp-cached-region', RAMP_SERVICE_ACCOUNT_PROVIDER_ID) + ).resolves.toMatchObject({ accessToken: token.accessToken, domain: 'eu-1' }) + } + expect(mint).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index ec263abf4d6..6fc0138e2dd 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -588,7 +588,8 @@ async function getTokenServiceAccountSecret( } interface CachedClientCredentialToken { - accessToken: string + /** The minted token and the destination fields it is bound to, exactly as tools receive them. */ + token: ServiceAccountTokenResult expiresAtMs: number /** * Fingerprint of the encrypted secret the token was minted from (see @@ -597,10 +598,6 @@ interface CachedClientCredentialToken { * the cached token belongs to the old app and must be re-minted. */ secretFingerprint: string - /** Salesforce or NetSuite — the provider origin returned alongside the minted token. */ - instanceUrl?: string - /** Zoho Desk only — the Desk REST base derived from the token's api_domain. */ - apiDomain?: string } interface FailedClientCredentialMint { @@ -701,11 +698,7 @@ async function resolveClientCredentialAccountToken( cached.secretFingerprint === secretFingerprint && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS ) { - return { - accessToken: cached.accessToken, - instanceUrl: cached.instanceUrl, - apiDomain: cached.apiDomain, - } + return cached.token } const failed = clientCredentialMintFailureCache.get(cacheIdentity) @@ -740,19 +733,19 @@ async function resolveClientCredentialAccountToken( }, { skipIdentity: true } ) + const token: ServiceAccountTokenResult = { + accessToken: mint.accessToken, + instanceUrl: mint.instanceUrl, + apiDomain: mint.apiDomain, + domain: mint.domain, + } if (!usesSharedToken) clientCredentialTokenCache.set(cacheIdentity, { - accessToken: mint.accessToken, + token, expiresAtMs: Date.now() + mint.expiresInSeconds * 1000, secretFingerprint, - instanceUrl: mint.instanceUrl, - apiDomain: mint.apiDomain, }) - return { - accessToken: mint.accessToken, - instanceUrl: mint.instanceUrl, - apiDomain: mint.apiDomain, - } + return token } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { clientCredentialMintFailureCache.set(cacheIdentity, { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2ffd1bd701c..2e86fc48e4f 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -36,6 +36,7 @@ import { CodaIcon, ConfluenceIcon, ConvexIcon, + CrowdStrikeIcon, CrunchbaseIcon, CursorIcon, DatabricksIcon, @@ -1659,6 +1660,24 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'fivetran', }, + crowdstrike: { + name: 'CrowdStrike', + icon: CrowdStrikeIcon, + services: { + crowdstrike: { + name: 'CrowdStrike', + description: + 'Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities.', + providerId: 'crowdstrike', + serviceAccountProviderId: 'crowdstrike-service-account', + authType: 'service_account', + icon: CrowdStrikeIcon, + baseProviderIcon: CrowdStrikeIcon, + scopes: [], + }, + }, + defaultService: 'crowdstrike', + }, vanta: { name: 'Vanta', icon: VantaIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 30f9189c6d7..e7bddcca046 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -587,6 +587,7 @@ describe('ordinary tool credential binding', () => { ['vanta', 'vanta-service-account'], ['ramp', 'ramp-service-account'], ['greenhouse', 'greenhouse-service-account'], + ['crowdstrike', 'crowdstrike-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d8221c2ff05..5daf8eed9ce 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -115,6 +115,7 @@ export type OAuthProvider = | 'ramp' | 'helpscout' | 'fivetran' + | 'crowdstrike' | 'vanta' | 'hubspot' | 'harmonic' @@ -291,6 +292,7 @@ export type OAuthService = | 'ramp' | 'helpscout' | 'fivetran' + | 'crowdstrike' | 'vanta' | 'hubspot' | 'harmonic' diff --git a/apps/sim/scripts/saved-credential-contract.test.ts b/apps/sim/scripts/saved-credential-contract.test.ts index 93a970b13d2..f80dc64cd5e 100644 --- a/apps/sim/scripts/saved-credential-contract.test.ts +++ b/apps/sim/scripts/saved-credential-contract.test.ts @@ -248,4 +248,76 @@ describe('savedCredentialContractErrors', () => { expect(errors).toEqual([expect.stringContaining('no credential policy')]) }) + + /** An operation tool that mints its own token from inline client credentials, at a chosen region. */ + function mintingTool(oauth: OAuthConfig) { + const executable = { + ...operationTool('minting_fixture', { + accessToken: { type: 'string' }, + clientId: { type: 'string' }, + clientSecret: { type: 'string' }, + region: { type: 'string' }, + }), + oauth, + } + return { + metadata: () => executable as ToolMetadata, + executable: () => executable, + } + } + + const mintingOAuth: OAuthConfig = { + required: false, + provider: PROVIDER, + credentialKind: 'service-account', + credentialParams: { accessToken: 'accessToken', region: 'domain' }, + tokenReplacesParams: ['clientId', 'clientSecret'], + credentialSuppliedParams: ['accessToken', 'region', 'clientId', 'clientSecret'], + } + + it('passes a tool whose inline client credentials the minted token replaces', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool(mintingOAuth) + ) + + expect(errors).toEqual([]) + }) + + it('fails a token-replaced secret when the credential never fills the token', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ ...mintingOAuth, credentialParams: { region: 'domain' } }) + ) + + expect(errors).toContainEqual( + expect.stringContaining('needs credentialParams to fill "accessToken"') + ) + }) + + it('still fails a credential-supplied secret that neither the credential nor its token fills', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ ...mintingOAuth, tokenReplacesParams: ['clientId'] }) + ) + + expect(errors).toEqual([expect.stringContaining('"clientSecret" is credential-supplied')]) + }) + + it('still requires the region a minting tool sends its token to be filled by the credential', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ + ...mintingOAuth, + credentialParams: { accessToken: 'accessToken' }, + credentialSuppliedParams: ['accessToken', 'clientId', 'clientSecret'], + }) + ) + + expect(errors).toEqual([expect.stringContaining('"region" decides the host')]) + }) }) diff --git a/apps/sim/scripts/saved-credential-contract.ts b/apps/sim/scripts/saved-credential-contract.ts index 6ce31786fc5..c99d095088f 100644 --- a/apps/sim/scripts/saved-credential-contract.ts +++ b/apps/sim/scripts/saved-credential-contract.ts @@ -45,6 +45,8 @@ const DESTINATION_PARAM = const OPERATION_PAYLOAD_PARAMS: Readonly> = { // The page the remote browser opens; the token goes to api.browser-use.com. browser_use_run_task: ['startUrl'], + // A label CrowdStrike records on the RTR session; the token goes to the credential's Falcon cloud. + crowdstrike_init_rtr_session: ['origin'], // A guild id the operation never sends; it posts only to the channel's discord.com endpoint. discord_send_message: ['serverId'], // The audio Fireflies downloads; the token goes to api.fireflies.ai. @@ -307,8 +309,17 @@ export function savedCredentialContractErrors( } const mapped = new Set(Object.keys(oauth.credentialParams ?? {})) if (!OPERATION_RESOLVED_CREDENTIAL_PROVIDERS.has(oauth.provider)) { + if ( + oauth.tokenReplacesParams?.length && + oauth.credentialParams?.accessToken !== 'accessToken' + ) { + errors.push( + `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs credentialParams to fill "accessToken" with the minted token.` + ) + } + const replacedByToken = new Set(oauth.tokenReplacesParams ?? []) for (const param of oauth.credentialSuppliedParams ?? []) { - if (!mapped.has(param)) { + if (!mapped.has(param) && !replacedByToken.has(param)) { errors.push( `Block "${block.type}" → tool "${toolId}": "${param}" is credential-supplied but nothing fills it (add it to credentialParams).` ) diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 6dc28653548..5d8f23345cf 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -29,6 +29,7 @@ import { CloudTrailBlock, CloudTrailV2Block } from '@/blocks/blocks/cloudtrail' import { CloudWatchBlock, CloudWatchV2Block } from '@/blocks/blocks/cloudwatch' import { CodePipelineBlock, CodePipelineV2Block } from '@/blocks/blocks/codepipeline' import { ConvexBlock, ConvexV2Block } from '@/blocks/blocks/convex' +import { CrowdStrikeBlock, CrowdStrikeV2Block } from '@/blocks/blocks/crowdstrike' import { CrunchbaseBlock, CrunchbaseV2Block } from '@/blocks/blocks/crunchbase' import { CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DatabricksBlock, DatabricksV2Block } from '@/blocks/blocks/databricks' @@ -348,6 +349,12 @@ const VERSIONS: Array<{ { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, + { + legacy: CrowdStrikeBlock, + successor: CrowdStrikeV2Block, + secretIds: ['clientId', 'clientSecret', 'cloud'], + optionalIds: ['cloud'], + }, { legacy: InfisicalBlock, successor: InfisicalV2Block, diff --git a/apps/sim/tools/crowdstrike/create_indicators.ts b/apps/sim/tools/crowdstrike/create_indicators.ts index e1d2185a842..884b8548214 100644 --- a/apps/sim/tools/crowdstrike/create_indicators.ts +++ b/apps/sim/tools/crowdstrike/create_indicators.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeCreateIndicatorsParams, CrowdStrikeCreateIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< description: 'Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/indicators/v1). Each indicator can allow, detect, or block activity across the fleet, so a wrong value can suppress detections or break legitimate software. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, indicators: { type: 'json', required: true, @@ -65,6 +73,7 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, ignoreWarnings: params.ignoreWarnings, indicators: params.indicators, diff --git a/apps/sim/tools/crowdstrike/delete_indicators.ts b/apps/sim/tools/crowdstrike/delete_indicators.ts index 6ccba973ecc..ff5e34246dc 100644 --- a/apps/sim/tools/crowdstrike/delete_indicators.ts +++ b/apps/sim/tools/crowdstrike/delete_indicators.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeDeleteIndicatorsParams, CrowdStrikeDeleteIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< description: 'Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /iocs/entities/indicators/v1). Cannot be undone; deleting a blocking indicator removes that protection from every host, and a broad filter can delete far more than intended. Supply an ID list or a filter, never both -- CrowdStrike lets a filter silently override the IDs, so this tool rejects that instead. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, indicatorIds: { type: 'json', required: false, @@ -59,6 +67,7 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, filter: params.filter, indicatorIds: params.indicatorIds, diff --git a/apps/sim/tools/crowdstrike/delete_rtr_session.ts b/apps/sim/tools/crowdstrike/delete_rtr_session.ts index 7c4d572956e..71be622a4d0 100644 --- a/apps/sim/tools/crowdstrike/delete_rtr_session.ts +++ b/apps/sim/tools/crowdstrike/delete_rtr_session.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeDeleteRtrSessionParams, CrowdStrikeDeleteRtrSessionResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< description: 'Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-response/entities/sessions/v1). Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, sessionId: { type: 'string', required: true, @@ -46,6 +54,7 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_delete_rtr_session', sessionId: params.sessionId, }), diff --git a/apps/sim/tools/crowdstrike/execute_rtr_command.ts b/apps/sim/tools/crowdstrike/execute_rtr_command.ts index 38ba5949cf7..daf3d7133aa 100644 --- a/apps/sim/tools/crowdstrike/execute_rtr_command.ts +++ b/apps/sim/tools/crowdstrike/execute_rtr_command.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeExecuteRtrCommandParams, CrowdStrikeExecuteRtrCommandResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< description: 'Run a read-only Real Time Response command in an open CrowdStrike Falcon session (POST /real-time-response/entities/command/v1). baseCommand names the family only (cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users); subcommands go in commandString. Host-modifying commands need the Active Responder or Admin endpoints. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, sessionId: { type: 'string', required: true, @@ -61,6 +69,7 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, commandString: params.commandString, operation: 'crowdstrike_execute_rtr_command', sessionId: params.sessionId, diff --git a/apps/sim/tools/crowdstrike/get_alert_details.ts b/apps/sim/tools/crowdstrike/get_alert_details.ts index a4a2146bc86..2775a47034d 100644 --- a/apps/sim/tools/crowdstrike/get_alert_details.ts +++ b/apps/sim/tools/crowdstrike/get_alert_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetAlertDetailsParams, CrowdStrikeGetAlertDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< description: 'Get full CrowdStrike Falcon alert records for one or more composite alert IDs (POST /alerts/entities/alerts/v2). Requires the "Alerts: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, compositeIds: { type: 'json', required: true, @@ -52,6 +60,7 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, compositeIds: params.compositeIds, includeHidden: params.includeHidden, operation: 'crowdstrike_get_alert_details', diff --git a/apps/sim/tools/crowdstrike/get_case_details.ts b/apps/sim/tools/crowdstrike/get_case_details.ts index 3d74bc00420..5b743e02613 100644 --- a/apps/sim/tools/crowdstrike/get_case_details.ts +++ b/apps/sim/tools/crowdstrike/get_case_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetCaseDetailsParams, CrowdStrikeGetCaseDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon Case Management case records for one or more case IDs (POST /cases/entities/cases/v2). Requires the "Cases: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, caseIds: { type: 'json', required: true, @@ -47,6 +55,7 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_case_details', }), }, diff --git a/apps/sim/tools/crowdstrike/get_host_group_details.ts b/apps/sim/tools/crowdstrike/get_host_group_details.ts index 8c73e722779..ef37f49f271 100644 --- a/apps/sim/tools/crowdstrike/get_host_group_details.ts +++ b/apps/sim/tools/crowdstrike/get_host_group_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetHostGroupDetailsParams, CrowdStrikeGetHostGroupDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon host group records for one or more group IDs (GET /devices/entities/host-groups/v1). Requires the "Host groups: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, hostGroupIds: { type: 'json', required: true, @@ -46,6 +54,7 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, hostGroupIds: params.hostGroupIds, operation: 'crowdstrike_get_host_group_details', }), diff --git a/apps/sim/tools/crowdstrike/get_indicator_details.ts b/apps/sim/tools/crowdstrike/get_indicator_details.ts index 08c1fd4019d..95ee8a09c38 100644 --- a/apps/sim/tools/crowdstrike/get_indicator_details.ts +++ b/apps/sim/tools/crowdstrike/get_indicator_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetIndicatorDetailsParams, CrowdStrikeGetIndicatorDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< description: 'Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or more IOC IDs (GET /iocs/entities/indicators/v1). Requires the "IOC Management: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, indicatorIds: { type: 'json', required: true, @@ -46,6 +54,7 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, indicatorIds: params.indicatorIds, operation: 'crowdstrike_get_indicator_details', }), diff --git a/apps/sim/tools/crowdstrike/get_rtr_command_status.ts b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts index c9dbccd562a..20ad6102baa 100644 --- a/apps/sim/tools/crowdstrike/get_rtr_command_status.ts +++ b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetRtrCommandStatusParams, CrowdStrikeGetRtrCommandStatusResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< description: 'Get the status and output of a Real Time Response command by cloud request ID (GET /real-time-response/entities/command/v1). Long output is chunked across sequences, so increment the sequence ID to read the next chunk. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, cloudRequestId: { type: 'string', required: true, @@ -52,6 +60,7 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, cloudRequestId: params.cloudRequestId, operation: 'crowdstrike_get_rtr_command_status', sequenceId: params.sequenceId, diff --git a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts index 19ad9bef489..39d47477e3b 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetSensorAggregatesParams, CrowdStrikeGetSensorAggregatesResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< description: 'Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query body (POST /identity-protection/aggregates/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, aggregateQuery: { type: 'json', required: true, @@ -47,6 +55,7 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_sensor_aggregates', }), }, diff --git a/apps/sim/tools/crowdstrike/get_sensor_details.ts b/apps/sim/tools/crowdstrike/get_sensor_details.ts index a58f997c8fa..d74861ce411 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_details.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetSensorDetailsParams, CrowdStrikeGetSensorDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Identity Protection sensor details for one or more device IDs (POST /identity-protection/entities/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, ids: { type: 'json', required: true, @@ -46,6 +54,7 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, ids: params.ids, operation: 'crowdstrike_get_sensor_details', }), diff --git a/apps/sim/tools/crowdstrike/get_vulnerability_details.ts b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts index bc172aef105..4b2cd0d59a7 100644 --- a/apps/sim/tools/crowdstrike/get_vulnerability_details.ts +++ b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeGetVulnerabilityDetailsParams, CrowdStrikeGetVulnerabilityDetailsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerability IDs, including CVE, affected host, application, and remediation details (GET /spotlight/entities/vulnerabilities/v2). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, vulnerabilityIds: { type: 'json', required: true, @@ -46,6 +54,7 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_vulnerability_details', vulnerabilityIds: params.vulnerabilityIds, }), diff --git a/apps/sim/tools/crowdstrike/init_rtr_session.ts b/apps/sim/tools/crowdstrike/init_rtr_session.ts index 097644b25e7..bf7846f7538 100644 --- a/apps/sim/tools/crowdstrike/init_rtr_session.ts +++ b/apps/sim/tools/crowdstrike/init_rtr_session.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeInitRtrSessionParams, CrowdStrikeInitRtrSessionResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeInitRtrSessionTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< description: 'Open a CrowdStrike Falcon Real Time Response session against a host so read-only commands can be run on it (POST /real-time-response/entities/sessions/v1). This connects a live remote shell to the endpoint. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, deviceId: { type: 'string', required: true, @@ -58,6 +66,7 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceId: params.deviceId, operation: 'crowdstrike_init_rtr_session', origin: params.origin, diff --git a/apps/sim/tools/crowdstrike/perform_host_action.ts b/apps/sim/tools/crowdstrike/perform_host_action.ts index 75207fe7cbf..13cf6e54e0d 100644 --- a/apps/sim/tools/crowdstrike/perform_host_action.ts +++ b/apps/sim/tools/crowdstrike/perform_host_action.ts @@ -2,6 +2,7 @@ import type { CrowdStrikePerformHostActionParams, CrowdStrikePerformHostActionResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikePerformHostActionTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< description: 'Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Actions: contain, lift_containment, hide_host, unhide_host, detection_suppress, detection_unsuppress. contain network-isolates the host so it can only reach the Falcon cloud; hide_host removes the host record from the console. Both are immediately disruptive. Up to 100 host IDs per call. Requires the "Hosts: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, actionName: { type: 'string', required: true, @@ -54,6 +62,7 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceIds: params.deviceIds, operation: 'crowdstrike_perform_host_action', }), diff --git a/apps/sim/tools/crowdstrike/perform_host_group_action.ts b/apps/sim/tools/crowdstrike/perform_host_group_action.ts index 63af58d690f..4092a41a75e 100644 --- a/apps/sim/tools/crowdstrike/perform_host_group_action.ts +++ b/apps/sim/tools/crowdstrike/perform_host_group_action.ts @@ -2,6 +2,7 @@ import type { CrowdStrikePerformHostGroupActionParams, CrowdStrikePerformHostGroupActionResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< description: 'Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST /devices/entities/host-group-actions/v1). Group membership drives policy assignment, so changing it changes which policies apply to those hosts. Requires the "Host groups: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, actionName: { type: 'string', required: true, @@ -60,6 +68,7 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceIds: params.deviceIds, hostGroupId: params.hostGroupId, operation: 'crowdstrike_perform_host_group_action', diff --git a/apps/sim/tools/crowdstrike/query_alerts.ts b/apps/sim/tools/crowdstrike/query_alerts.ts index 53c7ae73d2e..f7bf2b750ab 100644 --- a/apps/sim/tools/crowdstrike/query_alerts.ts +++ b/apps/sim/tools/crowdstrike/query_alerts.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQueryAlertsParams, CrowdStrikeQueryAlertsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryAlertsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< description: 'Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return their composite IDs. Uses the current Alerts API (GET /alerts/queries/alerts/v2), which replaced the Detects API decommissioned on September 30, 2025. Requires the "Alerts: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: false, @@ -76,6 +84,7 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, includeHidden: params.includeHidden, limit: params.limit, diff --git a/apps/sim/tools/crowdstrike/query_cases.ts b/apps/sim/tools/crowdstrike/query_cases.ts index 8947349b1d1..da6bcd82053 100644 --- a/apps/sim/tools/crowdstrike/query_cases.ts +++ b/apps/sim/tools/crowdstrike/query_cases.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQueryCasesParams, CrowdStrikeQueryCasesResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryCasesTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< description: 'Search CrowdStrike Falcon Case Management cases with a Falcon Query Language filter and return their IDs (GET /cases/queries/cases/v1). Case Management supersedes the CrowdScore Incidents API, which CrowdStrike has removed from its published API spec. Requires the "Cases: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: false, @@ -71,6 +79,7 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_host_groups.ts b/apps/sim/tools/crowdstrike/query_host_groups.ts index 5fc76784fc8..cc4befe32b4 100644 --- a/apps/sim/tools/crowdstrike/query_host_groups.ts +++ b/apps/sim/tools/crowdstrike/query_host_groups.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQueryHostGroupsParams, CrowdStrikeQueryHostGroupsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< description: 'Search CrowdStrike Falcon host groups with a Falcon Query Language filter and return their IDs (GET /devices/queries/host-groups/v1). Requires the "Host groups: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: false, @@ -64,6 +72,7 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_indicators.ts b/apps/sim/tools/crowdstrike/query_indicators.ts index b9150ce2492..1aba17a390a 100644 --- a/apps/sim/tools/crowdstrike/query_indicators.ts +++ b/apps/sim/tools/crowdstrike/query_indicators.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQueryIndicatorsParams, CrowdStrikeQueryIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< description: 'Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Query Language filter and return their IDs (GET /iocs/queries/indicators/v1). Requires the "IOC Management: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: false, @@ -74,6 +82,7 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts index db35bef0a4c..188713a7e29 100644 --- a/apps/sim/tools/crowdstrike/query_sensors.ts +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQuerySensorsParams, CrowdStrikeQuerySensorsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQuerySensorsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< description: 'Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors -- and return their device IDs (GET /identity-protection/queries/devices/v1). Sort uses the dot form, for example status.desc. Requires the "Identity Protection Entities: Read" API scope, a separate entitlement from Hosts and Alerts.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: false, @@ -64,6 +72,7 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_vulnerabilities.ts b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts index 214225a95f4..c963b029ffb 100644 --- a/apps/sim/tools/crowdstrike/query_vulnerabilities.ts +++ b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeQueryVulnerabilitiesParams, CrowdStrikeQueryVulnerabilitiesResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< description: 'Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query Language filter and return their IDs (GET /spotlight/queries/vulnerabilities/v1). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, filter: { type: 'string', required: true, @@ -66,6 +74,7 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, operation: 'crowdstrike_query_vulnerabilities', diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts index 133532b3982..d6e1688bc77 100644 --- a/apps/sim/tools/crowdstrike/types.ts +++ b/apps/sim/tools/crowdstrike/types.ts @@ -1,13 +1,26 @@ import type { ToolResponse } from '@/tools/types' -export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'us-3' | 'eu-1' | 'us-gov-1' | 'us-gov-2' +/** Falcon clouds Sim reaches; `lib/internal/crowdstrike/client` maps each to its API origin. */ +export const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2'] as const -export interface CrowdStrikeBaseParams { +export type CrowdStrikeCloud = (typeof CROWDSTRIKE_CLOUDS)[number] + +/** API client credentials typed inline on the legacy block. */ +export interface CrowdStrikeInlineCredentials { clientId: string clientSecret: string cloud: CrowdStrikeCloud } +/** + * Inline API client credentials, or the token and cloud origin the executor + * resolves from a saved CrowdStrike credential in their place. + */ +export interface CrowdStrikeBaseParams extends Partial { + accessToken?: string + instanceUrl?: string +} + export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { filter?: string limit?: number diff --git a/apps/sim/tools/crowdstrike/update_alerts.ts b/apps/sim/tools/crowdstrike/update_alerts.ts index 37299c31544..0333872dff9 100644 --- a/apps/sim/tools/crowdstrike/update_alerts.ts +++ b/apps/sim/tools/crowdstrike/update_alerts.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeUpdateAlertsParams, CrowdStrikeUpdateAlertsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeUpdateAlertsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< description: 'Update CrowdStrike Falcon alerts by composite ID: change status, assign or unassign an analyst, add or remove tags, append a comment, or toggle visibility (PATCH /alerts/entities/alerts/v3). This modifies live alerts in the Falcon console. Requires the "Alerts: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, compositeIds: { type: 'json', required: true, @@ -125,6 +133,7 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, compositeIds: params.compositeIds, includeHidden: params.includeHidden, operation: 'crowdstrike_update_alerts', diff --git a/apps/sim/tools/crowdstrike/update_indicators.ts b/apps/sim/tools/crowdstrike/update_indicators.ts index 183830ddbe8..ed809b74f0c 100644 --- a/apps/sim/tools/crowdstrike/update_indicators.ts +++ b/apps/sim/tools/crowdstrike/update_indicators.ts @@ -2,6 +2,7 @@ import type { CrowdStrikeUpdateIndicatorsParams, CrowdStrikeUpdateIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { CROWDSTRIKE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< @@ -13,6 +14,7 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< description: 'Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/entities/indicators/v1). DESTRUCTIVE: omitted fields may be cleared, so read each indicator with crowdstrike_get_indicator_details first and resend its full field set with your edits applied. Changing action or scope changes prevention behavior fleet-wide. type and value are immutable. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected CrowdStrike credential', + }, indicators: { type: 'json', required: true, @@ -65,6 +73,7 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, ignoreWarnings: params.ignoreWarnings, indicators: params.indicators, diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 16a46b76e23..d0ddfbb80a8 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -57,6 +57,30 @@ export function tokenCredentialOAuth( } as const satisfies OAuthConfig } +/** + * Credential policy for a tool that takes an API client's id and secret + * inline, mints its own token from them, and also accepts a saved + * client-credential account. With a credential selected, the executor fills + * the tool's hidden `accessToken` with the token minted from the saved account, + * plus every other mapped param (a cloud, site, or tenant the account is bound + * to); the inline secrets in `tokenReplacesParams` are then never read. + */ +export function clientCredentialOAuth( + provider: OAuthService, + credentialParams: Readonly>, + tokenReplacesParams: Replaced +) { + const filled = { accessToken: 'accessToken', ...credentialParams } as const + return { + required: false, + provider, + credentialKind: 'service-account', + credentialParams: filled, + tokenReplacesParams, + credentialSuppliedParams: [...Object.keys(filled), ...tokenReplacesParams], + } as const satisfies OAuthConfig +} + /** Whether a subblock is the saved-credential picker `withSavedCredential` inserts. */ export function isSavedCredentialPicker( subBlock: Pick @@ -93,6 +117,17 @@ export const DATABRICKS_CREDENTIAL_OAUTH = tokenCredentialOAuth('databricks', { host: 'domain', }) +/** + * Credential policy for every CrowdStrike tool. A saved CrowdStrike API client + * resolves to a token minted at its Falcon cloud; the credential fills the + * token and that cloud, and the token replaces the inline client id and secret. + */ +export const CROWDSTRIKE_CREDENTIAL_OAUTH = clientCredentialOAuth( + 'crowdstrike', + { cloud: 'domain' }, + ['clientId', 'clientSecret'] +) + /** Token credential policies for tools that take an inline key or a saved token credential. */ export const AFFINITY_CREDENTIAL_OAUTH = tokenCredentialOAuth('affinity', { apiKey: 'accessToken' }) export const AGENTMAIL_CREDENTIAL_OAUTH = tokenCredentialOAuth('agentmail', { @@ -346,6 +381,7 @@ export const SAVED_CREDENTIAL_OAUTH = { clerk: CLERK_CREDENTIAL_OAUTH, cloudflare: CLOUDFLARE_CREDENTIAL_OAUTH, convex: CONVEX_CREDENTIAL_OAUTH, + crowdstrike: CROWDSTRIKE_CREDENTIAL_OAUTH, crunchbase: CRUNCHBASE_CREDENTIAL_OAUTH, cursor: CURSOR_CREDENTIAL_OAUTH, databricks: DATABRICKS_CREDENTIAL_OAUTH, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 016b7f0c21a..1772a91e55e 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -154,6 +154,13 @@ export interface OAuthConfig { * operation instead (AWS session keys) leave it unset. */ credentialParams?: Readonly> + /** + * Inline secrets a saved client-credential account replaces with the token + * it mints (`credentialParams.accessToken`): the tool authenticates with the + * token and never reads them. Listed in `credentialSuppliedParams` too. + * Built by `clientCredentialOAuth`. + */ + tokenReplacesParams?: readonly string[] /** Token-response fields that must replace any caller-supplied tool parameter of the same name. */ authoritativeParams?: readonly ( | 'apiDomain' From 46ebf4c483e807dc9157771286d45c39fac0dc65 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:44:07 -0800 Subject: [PATCH 23/83] fix(credentials): accept only documented scoped-key answers from AgentMail and New Relic AgentMail now proves a key only on the two 403 codes its docs give for a valid key with the wrong scope (missing_permission, forbidden); a suspended account, a gateway deny, and any other refusal reject the key. New Relic reports an outage only for NerdGraph errors in the server classes its own client retries; BAD_API_KEY and unclassified errors reject the key. --- .../validators/token-checks.test.ts | 38 ++++++++++++-- .../validators/token-checks.ts | 51 +++++++++++-------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index b4d0877bbc1..4a320022707 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -190,6 +190,12 @@ describe('AgentMail saved API key verification', () => { ['the gateway refuses with a bare Forbidden', { message: 'Forbidden' }, 403], ['the key is unknown', { code: 'unknown_api_key', message: 'Unauthorized' }, 401], ['the key has expired', { code: 'api_key_expired', message: 'Unauthorized' }, 401], + ['the account is suspended', { code: 'account_suspended', message: 'Forbidden' }, 403], + [ + 'an API gateway denies the request', + { Message: 'User is not authorized to access this resource with an explicit deny' }, + 403, + ], ])('rejects the key when %s', async (_case, body, status) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(body, status))) @@ -214,11 +220,17 @@ describe('New Relic saved User key verification', () => { it('reports a NerdGraph error answer without a user as an outage, not a bad key', async () => { vi.stubGlobal( 'fetch', - vi - .fn() - .mockResolvedValue( - jsonResponse({ data: { actor: null }, errors: [{ message: 'Internal Server Error' }] }) - ) + vi.fn().mockResolvedValue( + jsonResponse({ + data: { actor: null }, + errors: [ + { + message: 'Internal Server Error', + extensions: { errorClass: 'INTERNAL_SERVER_ERROR' }, + }, + ], + }) + ) ) await expect( @@ -226,6 +238,22 @@ describe('New Relic saved User key verification', () => { ).rejects.toMatchObject({ code: 'provider_unavailable' }) }) + it('rejects a key NerdGraph names as bad in its errors array', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + data: { actor: null }, + errors: [{ message: 'Invalid API key', extensions: { error_code: 'BAD_API_KEY' } }], + }) + ) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-bad', domain: 'us' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + it('still rejects a NerdGraph answer that resolves no user and reports no error', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 9bd36168745..66cea066170 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -60,30 +60,24 @@ export const validateAffinityServiceAccount = tokenCheckValidator({ }), }) -/** AgentMail error codes that name the key itself as unusable. */ -const AGENTMAIL_REJECTED_KEY_CODES: ReadonlySet = new Set([ - 'missing_authorization', - 'invalid_token_type', - 'unknown_api_key', - 'api_key_expired', - 'unauthorized', +/** + * The AgentMail 403 codes its docs give for a valid key whose permissions or scope do not + * cover the request: https://docs.agentmail.to/knowledge-base/api-403-error + */ +const AGENTMAIL_SCOPED_KEY_CODES: ReadonlySet = new Set([ + 'missing_permission', + 'forbidden', ]) /** - * Listing one AgentMail inbox answers 200 for a valid key. An unknown or - * expired key gets a coded 401 or the gateway's bare `{"message":"Forbidden"}` - * 403; a valid key that lacks the inbox-read permission or is scoped to a pod - * or inbox gets a 403 coded `missing_permission` or `forbidden`, and can still - * run the tools its scope covers. + * Listing one AgentMail inbox answers 200 for a valid key. A valid key that lacks the + * inbox-read permission or is scoped to a pod or inbox gets a 403 coded `missing_permission` + * or `forbidden`, and can still run the tools its scope covers. Every other refusal, from + * the gateway's bare `{"message":"Forbidden"}` to a suspended account, rejects the key. */ export const validateAgentMailServiceAccount = tokenCheckValidator({ serviceLabel: 'AgentMail', - forbiddenMeansScoped: true, - rejectsKey: (errorBody) => { - const { code, message } = toRecord(errorBody) - if (code === undefined) return message === 'Forbidden' - return AGENTMAIL_REJECTED_KEY_CODES.has(String(code)) - }, + provesKey: (errorBody) => AGENTMAIL_SCOPED_KEY_CODES.has(toRecord(errorBody).code), request: ({ apiToken }) => ({ url: 'https://api.agentmail.to/v0/inboxes?limit=1', headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, @@ -1030,13 +1024,23 @@ export const validateMem0ServiceAccount = tokenCheckValidator({ }), }) +/** + * The NerdGraph error classes New Relic's own Go client retries: NerdGraph failing to + * answer, not a verdict on the key. + * https://github.com/newrelic/newrelic-client-go/blob/main/internal/http/graphql.go + */ +const NERDGRAPH_RETRYABLE_ERROR_CLASSES: ReadonlySet = new Set([ + 'TIMEOUT', + 'INTERNAL_SERVER_ERROR', + 'SERVER_ERROR', +]) + /** * A New Relic User API key is bound to its data center, so it is checked against the * NerdGraph host of the region typed with it (`us` or `eu`, stored lower-cased because * the tools send anything but `eu` to the US host). An unknown key, or one from the other - * region, gets 401, so only a resolved user proves the key. A 200 whose `errors` array - * left the user unresolved is NerdGraph failing to answer this fixed query, not a verdict - * on the key. + * region, gets 401, or a 200 whose `errors` carry `BAD_API_KEY`, so only a resolved user + * proves the key. Only an error in a server class means NerdGraph failed to answer. */ export const validateNewRelicServiceAccount = tokenCheckValidator({ serviceLabel: 'New Relic', @@ -1050,7 +1054,10 @@ export const validateNewRelicServiceAccount = tokenCheckValidator({ acceptsBody: (body) => { const { data, errors } = toRecord(body) if (toRecord(toRecord(data).actor).user != null) return true - if (toArray(errors).length > 0) { + const failedToAnswer = toArray(errors).some((error) => + NERDGRAPH_RETRYABLE_ERROR_CLASSES.has(toRecord(toRecord(error).extensions).errorClass) + ) + if (failedToAnswer) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'verify', reason: 'NerdGraph answered with errors and no user', From bd28fedb318e0d83ae1de6c8a68475abc6aebf0f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:44:08 -0800 Subject: [PATCH 24/83] fix(credentials): settle HubSpot service keys on the account-info route A token-info 200 without a portal now falls back to account-info instead of failing as an outage, and a token-info 401 falls back only for pat- keys, so other tokens keep being rejected there as before. The vendor-JSON 403 check moves to one shared helper. --- .../token-service-accounts/errors.ts | 8 ++++ .../token-service-accounts/validators/http.ts | 7 +-- .../validators/hubspot.test.ts | 25 ++++++++++ .../validators/hubspot.ts | 46 ++++++++++--------- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 79e4d985f33..6b4d85dd61f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -184,6 +184,14 @@ export interface ProviderResponse { body: ReadableStream | null } +/** Whether a 403 is the vendor's own JSON answer; an HTML one is a firewall or CDN page. */ +export function isVendorJsonForbidden(response: ProviderResponse): boolean { + return ( + response.status === 403 && + (response.headers.get('content-type') ?? '').includes('application/json') + ) +} + /** * Reads a bounded snippet of a provider error body for server logs. Never * throws — an unreadable body logs as an empty string. diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index e31c2be069e..82f9035072b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -9,6 +9,7 @@ import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { fetchProvider, isHostNotFound, + isVendorJsonForbidden, type ProviderResponse, providerFailureCode, TokenServiceAccountValidationError, @@ -258,11 +259,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid ? undefined : { dnsFailureCode: 'site_not_found' } ) - // A JSON 403 is the vendor's own answer; an HTML one is a firewall or CDN page. - const scopedForbidden = - check.forbiddenMeansScoped && - response.status === 403 && - (response.headers.get('content-type') ?? '').includes('application/json') + const scopedForbidden = check.forbiddenMeansScoped && isVendorJsonForbidden(response) if ((check.rejectsKey || check.provesKey) && !response.ok) { const unreadable = Symbol('unreadable') const errorBody = await readJsonBody(response, check.serviceLabel).catch(() => unreadable) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 0644438fd96..97b063afdf5 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -103,6 +103,31 @@ describe('validateHubspotServiceAccount', () => { expectFallbackCall() }) + it('rejects a token token-info answers with a 401 when it is not a pat- key', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ portalId: 456, uiDomain: 'app.hubspot.com' })) + + await expect( + validateHubspotServiceAccount({ apiToken: 'oauth-access-token' }) + ).rejects.toMatchObject({ code: 'invalid_credentials', status: 401 }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('verifies through account-info a key token-info answers without a portal', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({})) + .mockResolvedValueOnce(jsonResponse({ portalId: 789, uiDomain: 'app.hubspot.com' })) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toMatchObject({ + displayName: 'HubSpot portal 789', + principal: { kind: 'tenant', id: '789' }, + }) + expectFallbackCall() + }) + it('throws invalid_credentials when both routes answer 401', async () => { mockFetch .mockResolvedValueOnce( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 9198c213b42..84ca61b4a89 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,10 +1,9 @@ import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, + isVendorJsonForbidden, parseProviderJson, providerFailureCode, - readProviderErrorSnippet, - TokenServiceAccountValidationError, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { @@ -44,10 +43,10 @@ async function verifyViaAccountInfo( }, 'account_info' ) - if (res.status === 403 && (res.headers.get('content-type') ?? '').includes('application/json')) { + if (isVendorJsonForbidden(res)) { // The token is live but cannot read account info, so neither the portal // nor the creating user is knowable on this path. - await readProviderErrorSnippet(res) + await res.body?.cancel().catch(() => undefined) return { displayName: 'HubSpot', principal: null, @@ -67,6 +66,9 @@ async function verifyViaAccountInfo( } } +/** The documented prefix of HubSpot service keys and private app tokens. */ +const HUBSPOT_KEY_PREFIX = /^pat-/ + /** * Validates a HubSpot service key or private app access token. * @@ -76,13 +78,15 @@ async function verifyViaAccountInfo( * tokens, so one code path covers both regions. * * That endpoint is documented only for private app tokens, and live probing - * shows it returns a bare 404 (HTML) for a token it does not recognize, so - * any non-transient 4xx falls back to the Account Information API, which - * distinguishes a rejected token (JSON 401) from a live one (200/403). + * shows it returns a bare 404 (HTML) for a token it does not recognize. So a + * 400, 403 or 404, a 401 for a `pat-` key (which may be a service key), or a + * 200 without a portal falls back to the Account Information API, which + * distinguishes a rejected token (JSON 401) from a live one (200/403). Any + * other token it answers with 401 is rejected there. * - * The display name is always `HubSpot portal {hubId}` — the account-info - * `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), not a - * portal-specific name, so it is not used. + * The display name is `HubSpot portal {hubId}` when the portal is known — the + * account-info `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), + * not a portal-specific name, so it is not used. */ export async function validateHubspotServiceAccount( fields: TokenServiceAccountFields @@ -102,22 +106,22 @@ export async function validateHubspotServiceAccount( }, 'access_token_info' ) - if (!res.ok && providerFailureCode(res.status) === 'invalid_credentials') { - // Not decisive: this route 404s tokens it does not recognize and covers - // only private apps, so a service key may be refused here yet work on - // every REST route. Resolve via a regular API route with JSON errors. - await readProviderErrorSnippet(res) + const inconclusive = + !res.ok && + providerFailureCode(res.status) === 'invalid_credentials' && + (res.status !== 401 || HUBSPOT_KEY_PREFIX.test(accessToken)) + if (inconclusive) { + // This route 404s tokens it does not recognize and covers only private + // apps, so a service key may be refused here yet work on every REST route. + // Resolve via a regular API route with JSON errors. + await res.body?.cancel().catch(() => undefined) return verifyViaAccountInfo(accessToken) } await throwForProviderResponse(res, 'access_token_info') const tokenInfo = await parseProviderJson(res, 'access_token_info') - if (typeof tokenInfo?.hubId !== 'number') { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'access_token_info', - reason: 'missing hubId in response', - }) - } + // A 200 without a portal is not a private app's answer; let the REST route decide. + if (typeof tokenInfo?.hubId !== 'number') return verifyViaAccountInfo(accessToken) const hubId = String(tokenInfo.hubId) From 6d0e2328ecb059e94344d878497ce8b7fec9187c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:44:09 -0800 Subject: [PATCH 25/83] docs(credentials): align HubSpot, Infisical, and Bitbucket saved-key copy with vendor docs --- .../content/docs/integrations/bitbucket-service-account.mdx | 2 +- .../content/docs/integrations/hubspot-service-account.mdx | 2 +- apps/docs/content/docs/integrations/hubspot-setup.mdx | 2 +- apps/docs/content/docs/integrations/infisical.mdx | 2 +- .../sim/lib/credentials/token-service-accounts/descriptors.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx index 19e379caebd..48afdbbf106 100644 --- a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx +++ b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx @@ -36,7 +36,7 @@ A repository access token can only reach its repository, and a project access to -Sim also accepts an Atlassian API token created for Bitbucket (**Create API token with scopes**, then **Bitbucket**) with at least **Repositories: Read** (`read:repository:bitbucket`). An API token belongs to the person who created it and acts as them. Bitbucket app passwords no longer work: Atlassian stopped new app passwords on September 9, 2025 and set June 9, 2026 as the date existing ones stop working. See [Using API tokens](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/). +Sim also accepts an Atlassian API token created for Bitbucket (**Create API token with scopes**, then **Bitbucket**) with at least **Repositories: Read** (`read:repository:bitbucket`). That scope does not cover pull requests, so pull request operations also need **Pull requests: Read** (`read:pullrequest:bitbucket`), plus **Pull requests: Write** (`write:pullrequest:bitbucket`) to change them. An API token belongs to the person who created it and acts as them. Bitbucket app passwords no longer work: Atlassian stopped new app passwords on September 9, 2025 and set June 9, 2026 as the date existing ones stop working. See [Using API tokens](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/). ## Add the access token to Sim [#adding-the-access-token-to-sim] diff --git a/apps/docs/content/docs/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/integrations/hubspot-service-account.mdx index 32901cb640f..9c4e66301d6 100644 --- a/apps/docs/content/docs/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/integrations/hubspot-service-account.mdx @@ -22,7 +22,7 @@ Super admins and users with the **Developer tools access** permission can create - In HubSpot, navigate to **Development**, then **Keys** → **Service keys** + In HubSpot, navigate to **Development**, then **Keys** → **Service keys**. In some accounts the page is under **Settings** → **Integrations** → **Service keys** instead. Click **Create service key** and give it a name (e.g. `sim-hubspot`) diff --git a/apps/docs/content/docs/integrations/hubspot-setup.mdx b/apps/docs/content/docs/integrations/hubspot-setup.mdx index 0277b318055..ae371682814 100644 --- a/apps/docs/content/docs/integrations/hubspot-setup.mdx +++ b/apps/docs/content/docs/integrations/hubspot-setup.mdx @@ -24,7 +24,7 @@ You need: 1. Log in to Sim, open your workspace, and click **Integrations** in the sidebar. 2. Search for **HubSpot** and open it. -3. Click **Add to Sim** and choose **Connect with OAuth**. For a private app token, follow the [Private App Tokens guide](/integrations/hubspot-service-account). +3. Click **Add to Sim** and choose **Connect with OAuth**. For a service key or an existing private app token, follow the [service keys and private app tokens guide](/integrations/hubspot-service-account). Keys > Service keys; new legacy private apps can no longer be created, but existing private app tokens keep working. If the key rotates automatically, paste the new key here after each rotation.', + 'Create a service key in HubSpot under Development > Keys > Service keys (or Settings > Integrations > Service keys, depending on your account); new legacy private apps can no longer be created, but existing private app tokens keep working. If the key rotates automatically, paste the new key here after each rotation.', }, [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2302,7 +2302,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/infisical', helpText: - 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim does not renew the token, so it stops working when its Access Token TTL runs out (30 days by default): set a long Access Token TTL and Max TTL on the identity, and leave Access Token Period unset, since a periodic token expires unless it is renewed.', + 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim does not renew the token, so it stops working when its Access Token TTL runs out: set a long Access Token TTL and Max TTL on the identity, and leave Access Token Period unset, since a periodic token expires unless it is renewed.', invalidCredentialsHelp: 'Paste a machine identity access token, not a client secret or service token, and make sure the instance URL matches the region the identity lives in.', }, From 7b558b31dc24a9abc76db128a68250a73d14322b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:44:32 -0800 Subject: [PATCH 26/83] feat(credentials): saved Zendesk credentials with client credentials or an API token Zendesk gets a zendesk-service-account credential with two sign-ins: a confidential OAuth client exchanged through the client-credentials grant (POST /oauth/tokens, JSON, an explicit read or read write scope, a one-hour expires_in), or an agent email and API token sent as the documented email/token Basic credential. The subdomain is normalized and must match ^[a-z0-9][a-z0-9-]*$ before any request, and connecting requires users/me to answer with a real (non-anonymous) agent or admin. The credential resolves to its token, its subdomain as domain, and authStyle basic for the API-token sign-in. zendesk_v2 is the credential-only successor; every Zendesk tool reads the token through one header helper and takes its subdomain from the credential. The legacy zendesk block and its inline email, API token, and subdomain keep signing in exactly as before. --- .../content/docs/integrations/zendesk.mdx | 9 + apps/sim/blocks/blocks/zendesk.ts | 17 ++ apps/sim/blocks/registry-maps.ts | 3 +- .../lib/api/contracts/oauth-connections.ts | 2 +- .../client-credential-accounts/descriptors.ts | 124 ++++++++++ .../minters/zendesk.test.ts | 163 ++++++++++++++ .../minters/zendesk.ts | 213 ++++++++++++++++++ .../client-credential-accounts/server.ts | 22 +- .../service-account-secret.test.ts | 50 +++- .../lib/credentials/service-account-secret.ts | 1 + apps/sim/lib/oauth/credential-service.ts | 8 +- apps/sim/lib/oauth/oauth.ts | 18 ++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/serializer/saved-credentials.test.ts | 6 + apps/sim/tools/shared/saved-credentials.ts | 17 ++ .../zendesk/autocomplete_organizations.ts | 18 +- apps/sim/tools/zendesk/create_organization.ts | 18 +- .../zendesk/create_organizations_bulk.ts | 24 +- apps/sim/tools/zendesk/create_ticket.ts | 19 +- apps/sim/tools/zendesk/create_tickets_bulk.ts | 24 +- apps/sim/tools/zendesk/create_user.ts | 24 +- apps/sim/tools/zendesk/create_users_bulk.ts | 24 +- apps/sim/tools/zendesk/delete_organization.ts | 23 +- apps/sim/tools/zendesk/delete_ticket.ts | 24 +- apps/sim/tools/zendesk/delete_user.ts | 23 +- apps/sim/tools/zendesk/get_current_user.ts | 24 +- apps/sim/tools/zendesk/get_organization.ts | 18 +- apps/sim/tools/zendesk/get_organizations.ts | 18 +- apps/sim/tools/zendesk/get_ticket.ts | 19 +- apps/sim/tools/zendesk/get_tickets.ts | 19 +- apps/sim/tools/zendesk/get_user.ts | 24 +- apps/sim/tools/zendesk/get_users.ts | 18 +- apps/sim/tools/zendesk/merge_tickets.ts | 24 +- apps/sim/tools/zendesk/search.ts | 18 +- apps/sim/tools/zendesk/search_count.ts | 23 +- apps/sim/tools/zendesk/search_users.ts | 18 +- apps/sim/tools/zendesk/types.test.ts | 33 +++ apps/sim/tools/zendesk/types.ts | 42 +++- apps/sim/tools/zendesk/update_organization.ts | 18 +- apps/sim/tools/zendesk/update_ticket.ts | 19 +- apps/sim/tools/zendesk/update_tickets_bulk.ts | 24 +- apps/sim/tools/zendesk/update_user.ts | 24 +- apps/sim/tools/zendesk/update_users_bulk.ts | 24 +- 43 files changed, 1049 insertions(+), 234 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts create mode 100644 apps/sim/tools/zendesk/types.test.ts diff --git a/apps/docs/content/docs/integrations/zendesk.mdx b/apps/docs/content/docs/integrations/zendesk.mdx index d8e6e39f8ce..cf5fc981703 100644 --- a/apps/docs/content/docs/integrations/zendesk.mdx +++ b/apps/docs/content/docs/integrations/zendesk.mdx @@ -11,6 +11,15 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Zendesk](https://www.zendesk.com/) in Sim to manage tickets, users, and organizations and search support records. The reference includes single-record and bulk operations, ticket merging, organization autocomplete, and search counts. + +### Connect Zendesk + +Add a Zendesk credential under **Integrations** and pick it in the block. A credential holds your subdomain (`your-company` from `your-company.zendesk.com`) and one of two sign-ins: + +- **OAuth client (client credentials)**: the unique identifier and secret of a **confidential** OAuth client from Admin Center (**Apps and integrations** > **APIs** > **OAuth Clients**), plus the permissions to request (read only, or read and write). Sim requests a one-hour token with [Zendesk's client credentials grant](https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/) when a workflow needs one. Every call runs as the user the OAuth client belongs to. +- **API token**: an agent or admin email and an [API token](https://developer.zendesk.com/api-reference/introduction/security-and-auth/). Every call runs as that agent. + +When you save, Sim checks that the credential signs in as a real agent or admin of that subdomain. Workflows that still type an email and API token into a Zendesk block keep working unchanged. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/sim/blocks/blocks/zendesk.ts b/apps/sim/blocks/blocks/zendesk.ts index a8d6ca9e07f..fe2063ccfac 100644 --- a/apps/sim/blocks/blocks/zendesk.ts +++ b/apps/sim/blocks/blocks/zendesk.ts @@ -1,10 +1,13 @@ import { ZendeskIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' import { getTrigger } from '@/triggers' export const ZendeskBlock: BlockConfig = { type: 'zendesk', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'zendesk_v2' }, name: 'Zendesk', description: 'Manage support tickets, users, and organizations in Zendesk', triggerAllowed: true, @@ -798,6 +801,20 @@ Return ONLY the search query - no explanations.`, }, } +export const ZendeskV2Block: BlockConfig = { + ...ZendeskBlock, + type: 'zendesk_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ZendeskBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'zendesk', + title: 'Zendesk Credential', + }), +} + export const ZendeskBlockMeta = { tags: ['customer-support', 'ticketing'], url: 'https://www.zendesk.com', diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 1e0b4959fb3..600cd64e746 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -465,7 +465,7 @@ import { WorkflowInputBlock } from '@/blocks/blocks/workflow_input' import { XBlock, XBlockMeta } from '@/blocks/blocks/x' import { YouComBlock, YouComBlockMeta, YouComV2Block } from '@/blocks/blocks/youcom' import { YouTubeBlock, YouTubeBlockMeta, YouTubeV2Block } from '@/blocks/blocks/youtube' -import { ZendeskBlock, ZendeskBlockMeta } from '@/blocks/blocks/zendesk' +import { ZendeskBlock, ZendeskBlockMeta, ZendeskV2Block } from '@/blocks/blocks/zendesk' import { ZepBlock, ZepBlockMeta, ZepV2Block } from '@/blocks/blocks/zep' import { ZeroBounceBlock, ZeroBounceBlockMeta } from '@/blocks/blocks/zerobounce' import { ZohoDeskBlock, ZohoDeskBlockMeta } from '@/blocks/blocks/zoho-desk' @@ -962,6 +962,7 @@ export const BLOCK_REGISTRY: Record = { youtube: YouTubeBlock, youtube_v2: YouTubeV2Block, zendesk: ZendeskBlock, + zendesk_v2: ZendeskV2Block, zep: ZepBlock, zep_v2: ZepV2Block, zoho_desk: ZohoDeskBlock, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 25af9d40e45..261a6ffb596 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -123,7 +123,7 @@ const oauthTokenResponseSchema = z.object({ providerWorkspaceId: z.string().optional(), realmId: z.string().optional(), quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), - authStyle: z.enum(['x-api-token', 'snowflake-workload-identity']).optional(), + authStyle: z.enum(['x-api-token', 'snowflake-workload-identity', 'basic']).optional(), }) /** Token material a resolved credential yields, on the wire and in-process alike. */ diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index a9d365612cd..ac4d61c6146 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -24,6 +24,7 @@ export type ClientCredentialAccountFieldId = | 'authMethod' | 'privateKey' | 'username' + | 'apiToken' /** * The field id that selects between a descriptor's auth methods. A descriptor @@ -136,6 +137,7 @@ export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-accoun export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const +export const ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zendesk-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -162,6 +164,7 @@ export type ClientCredentialAccountProviderId = | typeof HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID | typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID | typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -454,6 +457,40 @@ function buildDocuSignConsentUrl( return `https://${DOCUSIGN_ENVIRONMENTS[resolved].authHost}/oauth/auth?${params.toString()}` } +/** A Zendesk account subdomain: the `{subdomain}` in `{subdomain}.zendesk.com`. */ +export const ZENDESK_SUBDOMAIN_REGEX = /^[a-z0-9][a-z0-9-]*$/ + +/** + * Normalizes a pasted Zendesk subdomain, accepting the full `https://acme.zendesk.com` URL as + * well as the bare `acme`. Shared by the connect modal's format hint and the server-side minter + * so both judge the same value; the minter refuses anything that does not then match + * {@link ZENDESK_SUBDOMAIN_REGEX}. + */ +export function normalizeZendeskSubdomain(rawSubdomain: string): string { + return rawSubdomain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/[/?#].*$/, '') + .replace(/\.zendesk\.com$/, '') +} + +/** Zendesk's two server-to-server sign-ins: an OAuth client's client-credentials grant, or an API token. */ +export const ZENDESK_AUTH_METHODS = { + clientCredentials: 'client_credentials', + apiToken: 'api_token', +} as const + +/** Permissions a Zendesk client-credentials token requests. */ +const ZENDESK_SCOPE_OPTIONS: ReadonlyArray = [ + { value: 'read', label: 'Read only' }, + { value: 'read write', label: 'Read and write' }, +] + +export const ZENDESK_SCOPES: ReadonlySet = new Set( + ZENDESK_SCOPE_OPTIONS.map((option) => option.value) +) + export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor @@ -549,6 +586,83 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< consentUrl: (values, redirectUri) => buildDocuSignConsentUrl(values.clientId ?? '', values.dataCenter, redirectUri), }, + [ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Zendesk', + connectNoun: 'API credential', + fields: [ + { + id: 'authMethod', + label: 'Authentication method', + placeholder: 'Select a method', + secret: false, + optional: true, + options: [ + { + value: ZENDESK_AUTH_METHODS.clientCredentials, + label: 'OAuth client (client credentials)', + }, + { value: ZENDESK_AUTH_METHODS.apiToken, label: 'API token' }, + ], + }, + { + id: 'orgId', + label: 'Subdomain', + placeholder: 'your-company', + secret: false, + hintPattern: ZENDESK_SUBDOMAIN_REGEX, + hintNormalize: normalizeZendeskSubdomain, + hintMessage: 'Expected the subdomain from your-company.zendesk.com, like your-company.', + }, + { + id: 'clientId', + label: 'Client identifier', + placeholder: 'Paste the OAuth client unique identifier', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + hint: 'Create a confidential OAuth client in Admin Center under Apps and integrations > APIs > OAuth Clients.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + }, + { + id: 'scope', + label: 'Permissions', + placeholder: 'Select permissions', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + options: ZENDESK_SCOPE_OPTIONS, + hint: 'Every call runs as the user the OAuth client belongs to, with that user’s Zendesk role.', + }, + { + id: 'username', + label: 'Agent email', + placeholder: 'agent@your-company.com', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.apiToken], + hint: 'Every call runs as this agent or admin, with their Zendesk role.', + }, + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + secret: true, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.apiToken], + }, + ], + defaultAuthMethod: ZENDESK_AUTH_METHODS.clientCredentials, + docsUrl: 'https://docs.sim.ai/integrations/zendesk', + helpText: 'Sim connects only to your-company.zendesk.com; host-mapped domains are not used.', + }, [RAMP_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: RAMP_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Ramp', @@ -1031,6 +1145,16 @@ export function resolveSalesforceAuthMethod(authMethod: string | undefined): str ) } +/** Resolves a stored Zendesk auth method against the descriptor's options, for the minter. */ +export function resolveZendeskAuthMethod(authMethod: string | undefined): string { + return ( + resolveClientCredentialAuthMethod( + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID], + authMethod + ) ?? ZENDESK_AUTH_METHODS.clientCredentials + ) +} + export function isClientCredentialAccountProviderId( value: string | null | undefined ): value is ClientCredentialAccountProviderId { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts new file mode 100644 index 00000000000..00ccdccd258 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts @@ -0,0 +1,163 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintZendeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zendesk' + +const CLIENT = { + authMethod: 'client_credentials', + orgId: 'https://Acme.zendesk.com/', + clientId: 'sim_client', + clientSecret: 'client-secret', + scope: 'read write', +} + +const API_TOKEN = { + authMethod: 'api_token', + orgId: 'acme', + clientId: '', + username: 'agent@example.com', + apiToken: 'zendesk-api-token', +} + +const AGENT = { user: { id: 42, role: 'agent', email: 'agent@example.com', name: 'Agent' } } + +const mockFetch = vi.fn() + +function requestOf(call: number): Request { + const [input, init] = mockFetch.mock.calls[call] + return new Request(input, init) +} + +describe('Zendesk credential minting', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it('exchanges an OAuth client for a bearer token on the account subdomain only', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ access_token: 'bearer-1', token_type: 'bearer', scope: 'read write' }) + ) + .mockResolvedValueOnce(jsonResponse(AGENT)) + + const result = await mintZendeskServiceAccountToken(CLIENT) + + const exchange = requestOf(0) + expect(exchange.url).toBe('https://acme.zendesk.com/oauth/tokens') + expect(exchange.method).toBe('POST') + expect(exchange.redirect).toBe('error') + expect(exchange.headers.get('content-type')).toBe('application/json') + expect(await exchange.json()).toEqual({ + grant_type: 'client_credentials', + client_id: 'sim_client', + client_secret: 'client-secret', + scope: 'read write', + expires_in: 3600, + }) + const me = requestOf(1) + expect(me.url).toBe('https://acme.zendesk.com/api/v2/users/me.json') + expect(me.headers.get('authorization')).toBe('Bearer bearer-1') + expect(me.redirect).toBe('error') + expect(result).toMatchObject({ + accessToken: 'bearer-1', + expiresInSeconds: 3600, + domain: 'acme', + identity: { principal: { kind: 'user', id: '42' } }, + }) + expect(result.authStyle).toBeUndefined() + expect(JSON.stringify(result.identity)).not.toContain('client-secret') + }) + + it('never keeps a token longer than the lifetime Sim requested', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ access_token: 'bearer-1', expires_in: 172800 })) + + const result = await mintZendeskServiceAccountToken(CLIENT, { skipIdentity: true }) + + expect(result.expiresInSeconds).toBe(3600) + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('derives the documented email/token Basic credential without calling Zendesk at run time', async () => { + const result = await mintZendeskServiceAccountToken(API_TOKEN, { skipIdentity: true }) + + expect(mockFetch).not.toHaveBeenCalled() + expect(result.authStyle).toBe('basic') + expect(result.domain).toBe('acme') + expect(Buffer.from(result.accessToken, 'base64').toString('utf8')).toBe( + 'agent@example.com/token:zendesk-api-token' + ) + }) + + it('verifies an API token sign-in with the same Basic header the tools send', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(AGENT)) + + const result = await mintZendeskServiceAccountToken(API_TOKEN) + + expect(requestOf(0).headers.get('authorization')).toBe(`Basic ${result.accessToken}`) + expect(result.identity?.principal).toMatchObject({ kind: 'user', id: '42' }) + }) + + it.each([ + ['an anonymous user', { user: { id: null, role: 'end-user', name: 'Anonymous user' } }], + ['an end user', { user: { id: 7, role: 'end-user' } }], + ['no user', {}], + ])('refuses a credential that signs in as %s', async (_label, body) => { + mockFetch.mockResolvedValueOnce(jsonResponse(body)) + + await expect(mintZendeskServiceAccountToken(API_TOKEN)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it.each([ + 'acme.zendesk.com.evil.example', + 'evil.example/acme', + 'acme.other', + '-acme', + 'localhost:8080', + '', + ])('refuses the subdomain %j before any request', async (orgId) => { + await expect(mintZendeskServiceAccountToken({ ...CLIENT, orgId })).rejects.toMatchObject({ + code: 'site_not_found', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + ['no scope', { scope: undefined }], + ['an unknown scope', { scope: 'impersonate' }], + ['no client secret', { clientSecret: undefined }], + ])('never requests a token with %s', async (_label, override) => { + await expect(mintZendeskServiceAccountToken({ ...CLIENT, ...override })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('never signs in with an API token missing its agent email', async () => { + await expect( + mintZendeskServiceAccountToken({ ...API_TOKEN, username: ' ' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [401, 'invalid_credentials'], + [400, 'invalid_credentials'], + [404, 'site_not_found'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'invalid_client' }, { status })) + + await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ code }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ token_type: 'bearer' })) + + await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts new file mode 100644 index 00000000000..8489d3f0ae6 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts @@ -0,0 +1,213 @@ +import { isRecordLike } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { + normalizeZendeskSubdomain, + resolveZendeskAuthMethod, + ZENDESK_AUTH_METHODS, + ZENDESK_SCOPES, + ZENDESK_SUBDOMAIN_REGEX, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { userPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + providerFailureCode, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const MINT_STEP = 'zendesk_token_mint' +const IDENTITY_STEP = 'zendesk_users_me' +const MAX_RESPONSE_BYTES = 64 * 1024 +/** Lifetime Sim requests; Zendesk accepts 300 seconds to 2 days. */ +const REQUESTED_TOKEN_LIFETIME_SECONDS = 3600 +/** An API-token sign-in mints nothing, so its derived credential is simply re-derived hourly. */ +const API_TOKEN_LIFETIME_SECONDS = 3600 +const STAFF_ROLES: ReadonlySet = new Set(['agent', 'admin']) + +function refuse( + code: 'invalid_credentials' | 'site_not_found' | 'provider_unavailable', + status: number, + step: string, + reason: string +): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError(code, status, { step, reason }) +} + +async function readJson( + response: Response, + label: string, + step: string, + signal?: AbortSignal +): Promise> { + try { + const payload = await readResponseJsonWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + label, + signal, + }) + if (isRecordLike(payload)) return payload + } catch { + signal?.throwIfAborted() + } + throw refuse('provider_unavailable', 502, step, 'provider returned an invalid response') +} + +async function throwForZendeskResponse(response: Response, step: string): Promise { + if (response.ok) return + const body = await readProviderErrorSnippet(response) + throw new TokenServiceAccountValidationError( + response.status === 404 ? 'site_not_found' : providerFailureCode(response.status), + response.status, + { step, body } + ) +} + +/** + * Exchanges a confidential OAuth client's credentials for a bearer token. Zendesk documents the + * grant only for confidential clients, as JSON, with a required scope and an optional + * `expires_in` it caps at two days; the token acts as the user the client belongs to. + */ +async function exchangeClientCredentials( + base: string, + fields: ClientCredentialAccountFields, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresInSeconds: number }> { + const scope = fields.scope?.trim() ?? '' + if (!fields.clientId || !fields.clientSecret || !ZENDESK_SCOPES.has(scope)) { + throw refuse('invalid_credentials', 400, MINT_STEP, 'client, secret, or scope is missing') + } + const response = await fetchProvider( + `${base}/oauth/tokens`, + { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: fields.clientSecret, + scope, + expires_in: REQUESTED_TOKEN_LIFETIME_SECONDS, + }), + redirect: 'error', + cache: 'no-store', + signal, + }, + MINT_STEP, + { dnsFailureCode: 'site_not_found', dnsFailureReason: 'subdomain does not resolve' } + ) + await throwForZendeskResponse(response, MINT_STEP) + const token = await readJson(response, 'Zendesk token response', MINT_STEP, signal) + if (typeof token.access_token !== 'string' || !token.access_token.trim()) { + throw refuse('provider_unavailable', 502, MINT_STEP, 'token response has no access token') + } + // Older clients may answer without a lifetime; never trust it beyond what Sim requested. + const reported = + typeof token.expires_in === 'number' && + Number.isSafeInteger(token.expires_in) && + token.expires_in > 0 + ? token.expires_in + : REQUESTED_TOKEN_LIFETIME_SECONDS + return { + accessToken: token.access_token, + expiresInSeconds: Math.min(reported, REQUESTED_TOKEN_LIFETIME_SECONDS), + } +} + +/** + * Proves the credential signs in as a real staff member: Zendesk answers an unauthenticated + * request to `users/me` with an anonymous user whose id is null, and an end user cannot use the + * ticketing API the tools call. + */ +async function verifyStaffUser( + base: string, + authorization: string, + subdomain: string, + signal?: AbortSignal +): Promise> { + const response = await fetchProvider( + `${base}/api/v2/users/me.json`, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: authorization }, + redirect: 'error', + cache: 'no-store', + signal, + }, + IDENTITY_STEP + ) + await throwForZendeskResponse(response, IDENTITY_STEP) + const payload = await readJson(response, 'Zendesk user response', IDENTITY_STEP, signal) + const user = isRecordLike(payload.user) ? payload.user : undefined + const id = user?.id + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) { + throw refuse('invalid_credentials', 401, IDENTITY_STEP, 'credential signs in anonymously') + } + if (typeof user?.role !== 'string' || !STAFF_ROLES.has(user.role)) { + throw refuse('invalid_credentials', 403, IDENTITY_STEP, 'user is not an agent or admin') + } + const email = typeof user.email === 'string' && user.email ? user.email : undefined + const name = typeof user.name === 'string' && user.name ? user.name : undefined + return { + displayName: `Zendesk ${subdomain}${email ? ` (${email})` : ''}`, + principal: userPrincipal(String(id), email ?? name), + auditMetadata: { zendeskSubdomain: subdomain, zendeskRole: user.role }, + storedMetadata: { zendeskRole: user.role }, + } +} + +/** + * Resolves a Zendesk credential to the authorization the Zendesk tools send. The OAuth client + * branch mints a bearer token through the client-credentials grant; the API-token branch derives + * the documented `{email}/token:{api_token}` Basic credential without a network call. Both act + * only on `{subdomain}.zendesk.com`, and the connect-time check requires a real agent or admin. + * + * @see https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/ + * @see https://developer.zendesk.com/api-reference/introduction/security-and-auth/ + */ +export async function mintZendeskServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const subdomain = normalizeZendeskSubdomain(fields.orgId) + if (!ZENDESK_SUBDOMAIN_REGEX.test(subdomain)) { + throw refuse('site_not_found', 400, 'host_validation', 'not a Zendesk subdomain') + } + const base = `https://${subdomain}.zendesk.com` + + let minted: Pick< + ClientCredentialAccountMintResult, + 'accessToken' | 'expiresInSeconds' | 'authStyle' + > + let authorization: string + if (resolveZendeskAuthMethod(fields.authMethod) === ZENDESK_AUTH_METHODS.apiToken) { + const email = fields.username?.trim() + if (!email || !fields.apiToken) { + throw refuse('invalid_credentials', 400, MINT_STEP, 'agent email or API token is missing') + } + const basic = Buffer.from(`${email}/token:${fields.apiToken}`).toString('base64') + minted = { + accessToken: basic, + expiresInSeconds: API_TOKEN_LIFETIME_SECONDS, + authStyle: 'basic', + } + authorization = `Basic ${basic}` + } else { + const exchanged = await exchangeClientCredentials(base, fields, options?.signal) + minted = exchanged + authorization = `Bearer ${exchanged.accessToken}` + } + + return { + ...minted, + domain: subdomain, + ...(options?.skipIdentity + ? {} + : { identity: await verifyStaffUser(base, authorization, subdomain, options?.signal) }), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index ae13062cac4..73b641acd4f 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -16,6 +16,7 @@ import { RAMP_SERVICE_ACCOUNT_PROVIDER_ID, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, VANTA_SERVICE_ACCOUNT_PROVIDER_ID, + ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' @@ -31,6 +32,7 @@ import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-cr import { mintRampServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/ramp' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintVantaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/vanta' +import { mintZendeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zendesk' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' @@ -71,8 +73,13 @@ export interface ClientCredentialAccountFields { * {@link clientSecret} in practice. */ privateKey?: string - /** User a key-based grant authenticates as (Salesforce JWT username, DocuSign user ID). */ + /** + * User a key-based grant authenticates as (Salesforce JWT username, DocuSign + * user ID), or the agent email a Zendesk API token signs in with. + */ username?: string + /** Long-lived API token for a provider's token sign-in branch (Zendesk API token). */ + apiToken?: string } /** Identity derived from a successful mint, used at connect time. */ @@ -116,6 +123,16 @@ export interface ClientCredentialAccountMintResult { apiDomain?: string /** Scopes granted to the app, when the provider reports them. */ grantedScopes?: string[] + /** + * Account host the token must be used against, forwarded to tools through + * their credential-mapped `domain` (the Zendesk subdomain). + */ + domain?: string + /** + * `basic` when `accessToken` is an HTTP Basic credential rather than a bearer + * token (a Zendesk API token sign-in). Absent means Bearer. + */ + authStyle?: 'basic' identity?: ClientCredentialAccountIdentity } @@ -161,6 +178,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: mintGreenhouseServiceAccountToken, [HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID]: mintHelpScoutServiceAccountToken, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: mintDocuSignServiceAccountToken, + [ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZendeskServiceAccountToken, } export function getClientCredentialAccountMinter( @@ -192,6 +210,8 @@ export interface ClientCredentialAccountSecretBlob { authMethod?: string privateKey?: string username?: string + /** Present only on a Zendesk API-token credential. */ + apiToken?: string metadata?: Record } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index ec8500c358e..d9f6d671f20 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -59,7 +59,8 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ providerId === 'box-service-account' || providerId === 'netsuite-service-account' || providerId === 'oracle-epm-service-account' || - providerId === 'oracle-fusion-service-account' + providerId === 'oracle-fusion-service-account' || + providerId === 'zendesk-service-account' ? mockClientCredentialMinter : undefined, })) @@ -274,6 +275,53 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('stores only the Zendesk sign-in the selected method uses', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'encoded', + expiresInSeconds: 3600, + authStyle: 'basic', + domain: 'acme', + identity: { + displayName: 'Zendesk acme', + principal: { kind: 'user', id: '42' }, + auditMetadata: {}, + }, + }) + const submitted = { + orgId: 'acme', + clientId: 'leftover-client', + clientSecret: 'leftover-secret', + scope: 'read', + username: ' agent@example.com ', + apiToken: ' zendesk-api-token ', + } + + const apiToken = await verifyAndBuildServiceAccountSecret('zendesk-service-account', { + ...submitted, + authMethod: 'api_token', + }) + const tokenBlob = JSON.parse(apiToken.encryptedServiceAccountKey) + expect(tokenBlob).toMatchObject({ + authMethod: 'api_token', + orgId: 'acme', + username: 'agent@example.com', + apiToken: 'zendesk-api-token', + }) + expect(tokenBlob.clientSecret).toBeUndefined() + expect(tokenBlob.scope).toBeUndefined() + + const client = await verifyAndBuildServiceAccountSecret('zendesk-service-account', submitted) + const clientBlob = JSON.parse(client.encryptedServiceAccountKey) + expect(clientBlob).toMatchObject({ + authMethod: 'client_credentials', + clientId: 'leftover-client', + clientSecret: 'leftover-secret', + scope: 'read', + }) + expect(clientBlob.apiToken).toBeUndefined() + expect(clientBlob.username).toBeUndefined() + }) + it('threads NetSuite certificate material into the minter and encrypted blob', async () => { mockClientCredentialMinter.mockResolvedValue({ accessToken: 'minted', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 69679d3072f..c6620632e67 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -650,6 +650,7 @@ async function buildClientCredentialAccountSecret( : undefined, privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, username: usesField('username') ? fields.username?.trim() || undefined : undefined, + apiToken: usesField('apiToken') ? fields.apiToken?.trim() || undefined : undefined, } // Requirements are per-auth-method, not per-provider: Salesforce's JWT diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 6a17ad2f695..f74daf7bea7 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -560,10 +560,11 @@ export interface ServiceAccountTokenResult { * Set when the token must be sent in an `x-api-token` header instead of * `Authorization: Bearer` (e.g. Pipedrive personal API tokens), or is a * Sim-signed OIDC token a keyless Snowflake credential presents through - * workload identity federation. Absent means Bearer; OAuth credentials never + * workload identity federation, or `basic` for an HTTP Basic credential (a + * Zendesk API token sign-in). Absent means Bearer; OAuth credentials never * carry it. */ - authStyle?: 'x-api-token' | 'snowflake-workload-identity' + authStyle?: 'x-api-token' | 'snowflake-workload-identity' | 'basic' } /** @@ -775,6 +776,7 @@ function resolveClientCredentialAccountToken( authMethod: blob.authMethod, privateKey: blob.privateKey, username: blob.username, + apiToken: blob.apiToken, }, { skipIdentity: true } ) @@ -783,6 +785,8 @@ function resolveClientCredentialAccountToken( accessToken: mint.accessToken, instanceUrl: mint.instanceUrl, apiDomain: mint.apiDomain, + ...(mint.domain ? { domain: mint.domain } : {}), + ...(mint.authStyle ? { authStyle: mint.authStyle } : {}), }, expiresInSeconds: mint.expiresInSeconds, } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2ffd1bd701c..400d16e4171 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -178,6 +178,7 @@ import { xIcon, YouComIcon, YouTubeIcon, + ZendeskIcon, ZepIcon, ZohoDeskIcon, ZoomIcon, @@ -2641,6 +2642,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'zep', }, + zendesk: { + name: 'Zendesk', + icon: ZendeskIcon, + services: { + zendesk: { + name: 'Zendesk', + description: 'Manage tickets, users, and organizations in Zendesk Support.', + providerId: 'zendesk', + serviceAccountProviderId: 'zendesk-service-account', + icon: ZendeskIcon, + baseProviderIcon: ZendeskIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'zendesk', + }, discord: { name: 'Discord', icon: DiscordIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d8221c2ff05..1c1e495b13c 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -169,6 +169,7 @@ export type OAuthProvider = | 'cursor' | 'discord' | 'zep' + | 'zendesk' | 'mem0' | 'youtube' | 'youcom' @@ -346,6 +347,7 @@ export type OAuthService = | 'cursor' | 'discord' | 'zep' + | 'zendesk' | 'mem0' | 'youtube' | 'youcom' diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 6dc28653548..753fcb0bb5d 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -120,6 +120,7 @@ import { VercelBlock, VercelV2Block } from '@/blocks/blocks/vercel' import { WhatsAppBlock, WhatsAppV2Block } from '@/blocks/blocks/whatsapp' import { YouComBlock, YouComV2Block } from '@/blocks/blocks/youcom' import { YouTubeBlock, YouTubeV2Block } from '@/blocks/blocks/youtube' +import { ZendeskBlock, ZendeskV2Block } from '@/blocks/blocks/zendesk' import { ZepBlock, ZepV2Block } from '@/blocks/blocks/zep' import type { BlockConfig } from '@/blocks/types' import { Serializer } from '@/serializer' @@ -347,6 +348,11 @@ const VERSIONS: Array<{ { legacy: UpstashBlock, successor: UpstashV2Block, secretIds: ['restUrl', 'restToken'] }, { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, + { + legacy: ZendeskBlock, + successor: ZendeskV2Block, + secretIds: ['email', 'apiToken', 'subdomain'], + }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, { legacy: InfisicalBlock, diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 16a46b76e23..b43befad7a5 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -320,6 +320,22 @@ export const YOUCOM_CREDENTIAL_OAUTH = tokenCredentialOAuth('youcom', { apiKey: export const YOUTUBE_CREDENTIAL_OAUTH = tokenCredentialOAuth('youtube', { apiKey: 'accessToken' }) export const ZEP_CREDENTIAL_OAUTH = tokenCredentialOAuth('zep', { apiKey: 'accessToken' }) +/** + * Credential policy for every Zendesk tool. A saved Zendesk credential resolves + * to an OAuth bearer token or an encoded API-token Basic credential (named by + * the authoritative `authStyle`) plus its subdomain, so the block's email, API + * token, and subdomain all come from the credential; the tools read the token + * from `accessToken`. + */ +export const ZENDESK_CREDENTIAL_OAUTH = { + required: false, + provider: 'zendesk', + credentialKind: 'service-account', + credentialParams: { subdomain: 'domain' }, + credentialSuppliedParams: ['email', 'apiToken', 'subdomain'], + authoritativeParams: ['authStyle'], +} as const satisfies OAuthConfig + /** * Every saved-credential policy keyed by the picker's service id. A * credential-only block replaces exactly the params its policy supplies, so @@ -425,6 +441,7 @@ export const SAVED_CREDENTIAL_OAUTH = { youcom: YOUCOM_CREDENTIAL_OAUTH, youtube: YOUTUBE_CREDENTIAL_OAUTH, zep: ZEP_CREDENTIAL_OAUTH, + zendesk: ZENDESK_CREDENTIAL_OAUTH, } as const export type SavedCredentialService = keyof typeof SAVED_CREDENTIAL_OAUTH diff --git a/apps/sim/tools/zendesk/autocomplete_organizations.ts b/apps/sim/tools/zendesk/autocomplete_organizations.ts index cd600a91617..7ece4bad118 100644 --- a/apps/sim/tools/zendesk/autocomplete_organizations.ts +++ b/apps/sim/tools/zendesk/autocomplete_organizations.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, @@ -5,6 +6,8 @@ import { METADATA_OUTPUT, ORGANIZATIONS_ARRAY_OUTPUT, PAGING_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskAutocompleteOrganizationsParams { @@ -43,7 +46,10 @@ export const zendeskAutocompleteOrganizationsTool: ToolConfig< 'Autocomplete organizations in Zendesk by name prefix (for name matching/autocomplete)', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -94,14 +100,10 @@ export const zendeskAutocompleteOrganizationsTool: ToolConfig< return `${url}?${query}` }, method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/create_organization.ts b/apps/sim/tools/zendesk/create_organization.ts index 558f2de3cd4..63fd6039e78 100644 --- a/apps/sim/tools/zendesk/create_organization.ts +++ b/apps/sim/tools/zendesk/create_organization.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, ORGANIZATION_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateOrganization') @@ -38,7 +41,10 @@ export const zendeskCreateOrganizationTool: ToolConfig< description: 'Create a new organization in Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -98,14 +104,10 @@ export const zendeskCreateOrganizationTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/organizations'), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const organization: any = { name: params.name, diff --git a/apps/sim/tools/zendesk/create_organizations_bulk.ts b/apps/sim/tools/zendesk/create_organizations_bulk.ts index 9e2933d4942..f43e0a540bf 100644 --- a/apps/sim/tools/zendesk/create_organizations_bulk.ts +++ b/apps/sim/tools/zendesk/create_organizations_bulk.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateOrganizationsBulk') @@ -29,7 +36,10 @@ export const zendeskCreateOrganizationsBulkTool: ToolConfig< description: 'Create multiple organizations in Zendesk using bulk import', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -60,14 +70,10 @@ export const zendeskCreateOrganizationsBulkTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/organizations/create_many'), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { try { const organizations = JSON.parse(params.organizations) diff --git a/apps/sim/tools/zendesk/create_ticket.ts b/apps/sim/tools/zendesk/create_ticket.ts index e04614ba4b5..ef9a3c394e7 100644 --- a/apps/sim/tools/zendesk/create_ticket.ts +++ b/apps/sim/tools/zendesk/create_ticket.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, TICKET_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateTicket') @@ -42,7 +45,10 @@ export const zendeskCreateTicketTool: ToolConfig< description: 'Create a new ticket in Zendesk with support for custom fields', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -126,15 +132,10 @@ export const zendeskCreateTicketTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/tickets'), method: 'POST', - headers: (params) => { - // Use Basic Authentication with email/token format for Zendesk API tokens - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const ticket: any = { subject: params.subject, diff --git a/apps/sim/tools/zendesk/create_tickets_bulk.ts b/apps/sim/tools/zendesk/create_tickets_bulk.ts index 97873df1a6d..e6266d4e202 100644 --- a/apps/sim/tools/zendesk/create_tickets_bulk.ts +++ b/apps/sim/tools/zendesk/create_tickets_bulk.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateTicketsBulk') @@ -29,7 +36,10 @@ export const zendeskCreateTicketsBulkTool: ToolConfig< description: 'Create multiple tickets in Zendesk at once (max 100)', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -60,14 +70,10 @@ export const zendeskCreateTicketsBulkTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/tickets/create_many'), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { try { const tickets = JSON.parse(params.tickets) diff --git a/apps/sim/tools/zendesk/create_user.ts b/apps/sim/tools/zendesk/create_user.ts index 9be8111d2ad..8d00a7daf3a 100644 --- a/apps/sim/tools/zendesk/create_user.ts +++ b/apps/sim/tools/zendesk/create_user.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, USER_OUTPUT_PROPERTIES } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + USER_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateUser') @@ -34,7 +41,10 @@ export const zendeskCreateUserTool: ToolConfig buildZendeskUrl(params.subdomain, '/users'), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const user: any = {} diff --git a/apps/sim/tools/zendesk/create_users_bulk.ts b/apps/sim/tools/zendesk/create_users_bulk.ts index 228068e8543..a4d25f990eb 100644 --- a/apps/sim/tools/zendesk/create_users_bulk.ts +++ b/apps/sim/tools/zendesk/create_users_bulk.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskCreateUsersBulk') @@ -29,7 +36,10 @@ export const zendeskCreateUsersBulkTool: ToolConfig< description: 'Create multiple users in Zendesk using bulk import', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -60,14 +70,10 @@ export const zendeskCreateUsersBulkTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/users/create_many'), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { try { const users = JSON.parse(params.users) diff --git a/apps/sim/tools/zendesk/delete_organization.ts b/apps/sim/tools/zendesk/delete_organization.ts index 2a3d596cdb8..8cb527fc181 100644 --- a/apps/sim/tools/zendesk/delete_organization.ts +++ b/apps/sim/tools/zendesk/delete_organization.ts @@ -1,5 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskDeleteOrganizationParams { email: string @@ -26,7 +32,10 @@ export const zendeskDeleteOrganizationTool: ToolConfig< description: 'Delete an organization from Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -56,14 +65,10 @@ export const zendeskDeleteOrganizationTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/organizations/${params.organizationId}`), method: 'DELETE', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/zendesk/delete_ticket.ts b/apps/sim/tools/zendesk/delete_ticket.ts index 900c343960b..f87667e2168 100644 --- a/apps/sim/tools/zendesk/delete_ticket.ts +++ b/apps/sim/tools/zendesk/delete_ticket.ts @@ -1,5 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskDeleteTicketParams { email: string @@ -26,7 +32,10 @@ export const zendeskDeleteTicketTool: ToolConfig< description: 'Delete a ticket from Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -56,15 +65,10 @@ export const zendeskDeleteTicketTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/tickets/${params.ticketId}`), method: 'DELETE', - headers: (params) => { - // Use Basic Authentication with email/token format for Zendesk API tokens - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/zendesk/delete_user.ts b/apps/sim/tools/zendesk/delete_user.ts index 87024ea7351..fa33fcfdc9b 100644 --- a/apps/sim/tools/zendesk/delete_user.ts +++ b/apps/sim/tools/zendesk/delete_user.ts @@ -1,5 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskDeleteUserParams { email: string @@ -24,7 +30,10 @@ export const zendeskDeleteUserTool: ToolConfig buildZendeskUrl(params.subdomain, `/users/${params.userId}`), method: 'DELETE', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/zendesk/get_current_user.ts b/apps/sim/tools/zendesk/get_current_user.ts index c787a8b0af3..a19e0beb4f0 100644 --- a/apps/sim/tools/zendesk/get_current_user.ts +++ b/apps/sim/tools/zendesk/get_current_user.ts @@ -1,5 +1,12 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, USER_OUTPUT_PROPERTIES } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + USER_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskGetCurrentUserParams { email: string @@ -25,7 +32,10 @@ export const zendeskGetCurrentUserTool: ToolConfig< description: 'Get the currently authenticated user from Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -49,14 +59,10 @@ export const zendeskGetCurrentUserTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/users/me'), method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_organization.ts b/apps/sim/tools/zendesk/get_organization.ts index a9aa45e378e..0f0a8dc73c2 100644 --- a/apps/sim/tools/zendesk/get_organization.ts +++ b/apps/sim/tools/zendesk/get_organization.ts @@ -1,8 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, ORGANIZATION_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskGetOrganizationParams { @@ -30,7 +33,10 @@ export const zendeskGetOrganizationTool: ToolConfig< description: 'Get a single organization by ID from Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -60,14 +66,10 @@ export const zendeskGetOrganizationTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/organizations/${params.organizationId}`), method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_organizations.ts b/apps/sim/tools/zendesk/get_organizations.ts index 14d23d3d20e..cf306ca3308 100644 --- a/apps/sim/tools/zendesk/get_organizations.ts +++ b/apps/sim/tools/zendesk/get_organizations.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { appendCursorPaginationParams, @@ -7,6 +8,8 @@ import { METADATA_OUTPUT, ORGANIZATIONS_ARRAY_OUTPUT, PAGING_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskGetOrganizationsParams { @@ -42,7 +45,10 @@ export const zendeskGetOrganizationsTool: ToolConfig< description: 'Retrieve a list of organizations from Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -85,14 +91,10 @@ export const zendeskGetOrganizationsTool: ToolConfig< return query ? `${url}?${query}` : url }, method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_ticket.ts b/apps/sim/tools/zendesk/get_ticket.ts index f3e75032420..b3b2e715597 100644 --- a/apps/sim/tools/zendesk/get_ticket.ts +++ b/apps/sim/tools/zendesk/get_ticket.ts @@ -1,8 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, TICKET_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskGetTicketParams { @@ -27,7 +30,10 @@ export const zendeskGetTicketTool: ToolConfig buildZendeskUrl(params.subdomain, `/tickets/${params.ticketId}`), method: 'GET', - headers: (params) => { - // Use Basic Authentication with email/token format for Zendesk API tokens - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_tickets.ts b/apps/sim/tools/zendesk/get_tickets.ts index 87405f1cd29..d301c5f06c6 100644 --- a/apps/sim/tools/zendesk/get_tickets.ts +++ b/apps/sim/tools/zendesk/get_tickets.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { appendCursorPaginationParams, @@ -7,6 +8,8 @@ import { METADATA_OUTPUT, PAGING_OUTPUT, TICKETS_ARRAY_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskGetTicketsParams { @@ -46,7 +49,10 @@ export const zendeskGetTicketsTool: ToolConfig { - // Use Basic Authentication with email/token format for Zendesk API tokens - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_user.ts b/apps/sim/tools/zendesk/get_user.ts index b46d3769317..e80c33acc35 100644 --- a/apps/sim/tools/zendesk/get_user.ts +++ b/apps/sim/tools/zendesk/get_user.ts @@ -1,5 +1,12 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, USER_OUTPUT_PROPERTIES } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + USER_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskGetUserParams { email: string @@ -23,7 +30,10 @@ export const zendeskGetUserTool: ToolConfig buildZendeskUrl(params.subdomain, `/users/${params.userId}`), method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/get_users.ts b/apps/sim/tools/zendesk/get_users.ts index 0efbc3c906c..7c8dd2ae56b 100644 --- a/apps/sim/tools/zendesk/get_users.ts +++ b/apps/sim/tools/zendesk/get_users.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { appendCursorPaginationParams, @@ -7,6 +8,8 @@ import { METADATA_OUTPUT, PAGING_OUTPUT, USERS_ARRAY_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskGetUsersParams { @@ -41,7 +44,10 @@ export const zendeskGetUsersTool: ToolConfig { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/merge_tickets.ts b/apps/sim/tools/zendesk/merge_tickets.ts index edb61b04a07..9812e716228 100644 --- a/apps/sim/tools/zendesk/merge_tickets.ts +++ b/apps/sim/tools/zendesk/merge_tickets.ts @@ -1,5 +1,12 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskMergeTicketsParams { email: string @@ -29,7 +36,10 @@ export const zendeskMergeTicketsTool: ToolConfig< description: 'Merge multiple tickets into a target ticket', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -72,14 +82,10 @@ export const zendeskMergeTicketsTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/tickets/${params.targetTicketId}/merge`), method: 'POST', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const ids = params.sourceTicketIds.split(',').map((id) => id.trim()) const body: any = { ids } diff --git a/apps/sim/tools/zendesk/search.ts b/apps/sim/tools/zendesk/search.ts index f6e18f9bd92..bbbfe5752b5 100644 --- a/apps/sim/tools/zendesk/search.ts +++ b/apps/sim/tools/zendesk/search.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { appendCursorPaginationParams, @@ -6,6 +7,8 @@ import { handleZendeskError, METADATA_OUTPUT, PAGING_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskSearchParams { @@ -40,7 +43,10 @@ export const zendeskSearchTool: ToolConfig { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/search_count.ts b/apps/sim/tools/zendesk/search_count.ts index e988763a044..977153cffe1 100644 --- a/apps/sim/tools/zendesk/search_count.ts +++ b/apps/sim/tools/zendesk/search_count.ts @@ -1,5 +1,11 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskSearchCountParams { email: string @@ -25,7 +31,10 @@ export const zendeskSearchCountTool: ToolConfig< description: 'Count the number of search results matching a query in Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -63,14 +72,10 @@ export const zendeskSearchCountTool: ToolConfig< return `${url}?${query}` }, method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/search_users.ts b/apps/sim/tools/zendesk/search_users.ts index 45bf868e2e4..4a752e69209 100644 --- a/apps/sim/tools/zendesk/search_users.ts +++ b/apps/sim/tools/zendesk/search_users.ts @@ -1,3 +1,4 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, @@ -5,6 +6,8 @@ import { METADATA_OUTPUT, PAGING_OUTPUT, USERS_ARRAY_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' export interface ZendeskSearchUsersParams { @@ -43,7 +46,10 @@ export const zendeskSearchUsersTool: ToolConfig< description: 'Search for users in Zendesk using a query string', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -101,14 +107,10 @@ export const zendeskSearchUsersTool: ToolConfig< return `${url}?${query}` }, method: 'GET', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/zendesk/types.test.ts b/apps/sim/tools/zendesk/types.test.ts new file mode 100644 index 00000000000..163d6fb6e28 --- /dev/null +++ b/apps/sim/tools/zendesk/types.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { zendeskGetTicketTool } from '@/tools/zendesk/get_ticket' + +const headersFor = (params: Record) => { + const build = zendeskGetTicketTool.request.headers + if (typeof build !== 'function') throw new Error('Zendesk headers are built per request') + return build({ subdomain: 'acme', ticketId: '1', ...params } as never) +} + +describe('Zendesk request authentication', () => { + it('signs in with the block’s own email and API token exactly as before', () => { + expect(headersFor({ email: 'agent@example.com', apiToken: 'inline-token' }).Authorization).toBe( + `Basic ${Buffer.from('agent@example.com/token:inline-token').toString('base64')}` + ) + }) + + it('sends a saved OAuth client token as a bearer token', () => { + expect(headersFor({ accessToken: 'bearer-1' }).Authorization).toBe('Bearer bearer-1') + }) + + it('sends a saved API-token credential as the Basic credential it already is', () => { + expect(headersFor({ accessToken: 'ZW5jb2RlZA==', authStyle: 'basic' }).Authorization).toBe( + 'Basic ZW5jb2RlZA==' + ) + }) + + it('lets a saved credential win over inline secrets left in a block’s state', () => { + expect( + headersFor({ email: 'old@example.com', apiToken: 'stale', accessToken: 'bearer-1' }) + .Authorization + ).toBe('Bearer bearer-1') + }) +}) diff --git a/apps/sim/tools/zendesk/types.ts b/apps/sim/tools/zendesk/types.ts index 8c87bb798c7..cc7facd1cf3 100644 --- a/apps/sim/tools/zendesk/types.ts +++ b/apps/sim/tools/zendesk/types.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { OutputProperty } from '@/tools/types' +import type { OutputProperty, ToolConfig } from '@/tools/types' const logger = createLogger('Zendesk') @@ -22,6 +22,46 @@ export function buildZendeskUrl(subdomain: string, path: string): string { return `https://${subdomain}.zendesk.com/api/v2${path}` } +/** Every input that decides how a Zendesk request authenticates. */ +export interface ZendeskAuthParams { + email?: string + apiToken?: string + /** Set by the executor from a saved Zendesk credential, never by the caller. */ + accessToken?: string + /** `basic` when the saved credential is an API token sign-in; absent means a bearer token. */ + authStyle?: string +} + +/** + * The Authorization header a Zendesk request sends. A saved credential resolves to either an + * OAuth bearer token or the already-encoded API-token Basic credential; without one, the + * block's own email and API token sign in exactly as before. + */ +export function zendeskAuthorization(params: ZendeskAuthParams): string { + if (params.accessToken) { + return params.authStyle === 'basic' + ? `Basic ${params.accessToken}` + : `Bearer ${params.accessToken}` + } + return `Basic ${Buffer.from(`${params.email}/token:${params.apiToken}`).toString('base64')}` +} + +/** Hidden params a saved Zendesk credential fills; every Zendesk tool declares them. */ +export const ZENDESK_CREDENTIAL_PARAMS = { + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Token injected by the executor from the selected Zendesk credential', + }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Token type injected by the executor from the selected Zendesk credential', + }, +} satisfies ToolConfig['params'] + // Helper function for consistent error handling export function handleZendeskError(data: any, status: number, operation: string): never { logger.error(`Zendesk API request failed for ${operation}`, { data, status }) diff --git a/apps/sim/tools/zendesk/update_organization.ts b/apps/sim/tools/zendesk/update_organization.ts index ea007609f6f..55655d15fa6 100644 --- a/apps/sim/tools/zendesk/update_organization.ts +++ b/apps/sim/tools/zendesk/update_organization.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, ORGANIZATION_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' const logger = createLogger('ZendeskUpdateOrganization') @@ -39,7 +42,10 @@ export const zendeskUpdateOrganizationTool: ToolConfig< description: 'Update an existing organization in Zendesk', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -105,14 +111,10 @@ export const zendeskUpdateOrganizationTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/organizations/${params.organizationId}`), method: 'PUT', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const organization: any = {} diff --git a/apps/sim/tools/zendesk/update_ticket.ts b/apps/sim/tools/zendesk/update_ticket.ts index 6c8bbc01fa5..7f25f412a5d 100644 --- a/apps/sim/tools/zendesk/update_ticket.ts +++ b/apps/sim/tools/zendesk/update_ticket.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { buildZendeskUrl, handleZendeskError, TICKET_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, } from '@/tools/zendesk/types' const logger = createLogger('ZendeskUpdateTicket') @@ -42,7 +45,10 @@ export const zendeskUpdateTicketTool: ToolConfig< description: 'Update an existing ticket in Zendesk with support for custom fields', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -126,15 +132,10 @@ export const zendeskUpdateTicketTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, `/tickets/${params.ticketId}`), method: 'PUT', - headers: (params) => { - // Use Basic Authentication with email/token format for Zendesk API tokens - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const ticket: any = {} diff --git a/apps/sim/tools/zendesk/update_tickets_bulk.ts b/apps/sim/tools/zendesk/update_tickets_bulk.ts index ac56fddb0ce..2d62871ba37 100644 --- a/apps/sim/tools/zendesk/update_tickets_bulk.ts +++ b/apps/sim/tools/zendesk/update_tickets_bulk.ts @@ -1,5 +1,12 @@ +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' export interface ZendeskUpdateTicketsBulkParams { email: string @@ -31,7 +38,10 @@ export const zendeskUpdateTicketsBulkTool: ToolConfig< description: 'Update multiple tickets in Zendesk at once (max 100)', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -95,14 +105,10 @@ export const zendeskUpdateTicketsBulkTool: ToolConfig< return buildZendeskUrl(params.subdomain, `/tickets/update_many?ids=${ids.join(',')}`) }, method: 'PUT', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const ticket: any = {} if (params.status) ticket.status = params.status diff --git a/apps/sim/tools/zendesk/update_user.ts b/apps/sim/tools/zendesk/update_user.ts index 2befd6c55ef..862a4b24791 100644 --- a/apps/sim/tools/zendesk/update_user.ts +++ b/apps/sim/tools/zendesk/update_user.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, USER_OUTPUT_PROPERTIES } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + USER_OUTPUT_PROPERTIES, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskUpdateUser') @@ -35,7 +42,10 @@ export const zendeskUpdateUserTool: ToolConfig buildZendeskUrl(params.subdomain, `/users/${params.userId}`), method: 'PUT', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { const user: any = {} diff --git a/apps/sim/tools/zendesk/update_users_bulk.ts b/apps/sim/tools/zendesk/update_users_bulk.ts index 37da2b5fbe4..cc40dc7ca58 100644 --- a/apps/sim/tools/zendesk/update_users_bulk.ts +++ b/apps/sim/tools/zendesk/update_users_bulk.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { ZENDESK_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' -import { buildZendeskUrl, handleZendeskError, JOB_STATUS_OUTPUT } from '@/tools/zendesk/types' +import { + buildZendeskUrl, + handleZendeskError, + JOB_STATUS_OUTPUT, + ZENDESK_CREDENTIAL_PARAMS, + zendeskAuthorization, +} from '@/tools/zendesk/types' const logger = createLogger('ZendeskUpdateUsersBulk') @@ -29,7 +36,10 @@ export const zendeskUpdateUsersBulkTool: ToolConfig< description: 'Update multiple users in Zendesk using bulk update', version: '1.0.0', + oauth: ZENDESK_CREDENTIAL_OAUTH, + params: { + ...ZENDESK_CREDENTIAL_PARAMS, email: { type: 'string', required: true, @@ -60,14 +70,10 @@ export const zendeskUpdateUsersBulkTool: ToolConfig< request: { url: (params) => buildZendeskUrl(params.subdomain, '/users/update_many'), method: 'PUT', - headers: (params) => { - const credentials = `${params.email}/token:${params.apiToken}` - const base64Credentials = Buffer.from(credentials).toString('base64') - return { - Authorization: `Basic ${base64Credentials}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + Authorization: zendeskAuthorization(params), + 'Content-Type': 'application/json', + }), body: (params) => { try { const users = JSON.parse(params.users) From 2962611f88daa32375ab5b7054424451c1a15f9a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:36:14 -0800 Subject: [PATCH 27/83] feat(credentials): PlanetScale saved service token credential A PlanetScale credential holds a service token and its ID, verified by listing the organizations the token reaches with the same ID:token Authorization the tools send; a JSON 403 is a valid token without that read. Every PlanetScale tool accepts it, the selectors gain saved-credential twins that read the pair from the credential, and planetscale_v2 is the credential-only successor. The legacy block keeps its inline inputs. --- apps/sim/blocks/blocks/planetscale.ts | 28 +++++++ apps/sim/blocks/registry-maps.ts | 7 +- .../token-service-accounts/descriptors.ts | 27 +++++++ .../token-service-accounts/server.ts | 3 + .../validators/token-checks.test.ts | 75 ++++++++++++++++++ .../validators/token-checks.ts | 25 ++++++ apps/sim/lib/oauth/oauth.ts | 18 +++++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/lib/selectors/manifest.ts | 25 ++++++ .../server/providers/planetscale.test.ts | 49 ++++++++++++ .../selectors/server/providers/planetscale.ts | 79 ++++++++++++++----- apps/sim/serializer/saved-credentials.test.ts | 6 ++ .../tools/planetscale/close-deploy-request.ts | 2 + apps/sim/tools/planetscale/create-backup.ts | 2 + apps/sim/tools/planetscale/create-branch.ts | 2 + .../planetscale/create-deploy-request.ts | 2 + apps/sim/tools/planetscale/delete-branch.ts | 2 + apps/sim/tools/planetscale/get-backup.ts | 2 + apps/sim/tools/planetscale/get-branch.ts | 2 + apps/sim/tools/planetscale/get-database.ts | 2 + .../tools/planetscale/get-deploy-request.ts | 2 + apps/sim/tools/planetscale/list-backups.ts | 2 + apps/sim/tools/planetscale/list-branches.ts | 2 + apps/sim/tools/planetscale/list-databases.ts | 2 + .../tools/planetscale/list-deploy-requests.ts | 2 + .../tools/planetscale/queue-deploy-request.ts | 2 + .../planetscale/review-deploy-request.ts | 2 + apps/sim/tools/shared/saved-credentials.ts | 5 ++ scripts/check-explicit-any.baseline.json | 1 - 29 files changed, 359 insertions(+), 21 deletions(-) diff --git a/apps/sim/blocks/blocks/planetscale.ts b/apps/sim/blocks/blocks/planetscale.ts index aa6013ae855..b9fa9557230 100644 --- a/apps/sim/blocks/blocks/planetscale.ts +++ b/apps/sim/blocks/blocks/planetscale.ts @@ -1,5 +1,6 @@ import { PlanetScaleIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' function optionalBoolean(value: unknown): boolean | undefined { if (value === undefined || value === null || value === '') return undefined @@ -12,6 +13,8 @@ function optionalNumber(value: unknown): number | undefined { } export const PlanetScaleBlock: BlockConfig = { type: 'planetscale', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'planetscale_v2' }, name: 'PlanetScale', description: 'Manage PlanetScale databases, branches, backups, and deploy requests', longDescription: @@ -1185,3 +1188,28 @@ export const PlanetScaleBlockMeta = { }, ], } as const satisfies BlockMeta + +export const PlanetScaleV2Block: BlockConfig = { + ...PlanetScaleBlock, + type: 'planetscale_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + PlanetScaleBlock, + { + id: 'credential', + type: 'oauth-input', + serviceId: 'planetscale', + title: 'PlanetScale Credential', + }, + { + selectorKeys: { + 'planetscale.databases': 'planetscale.savedDatabases', + 'planetscale.branches': 'planetscale.savedBranches', + 'planetscale.backups': 'planetscale.savedBackups', + 'planetscale.deployRequests': 'planetscale.savedDeployRequests', + }, + } + ), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 1e0b4959fb3..3f42fa19a95 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -327,7 +327,11 @@ import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PitchBookBlock, PitchBookBlockMeta } from '@/blocks/blocks/pitchbook' import { PlaneBlock, PlaneBlockMeta, PlaneV2Block } from '@/blocks/blocks/plane' -import { PlanetScaleBlock, PlanetScaleBlockMeta } from '@/blocks/blocks/planetscale' +import { + PlanetScaleBlock, + PlanetScaleBlockMeta, + PlanetScaleV2Block, +} from '@/blocks/blocks/planetscale' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql' import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog' @@ -798,6 +802,7 @@ export const BLOCK_REGISTRY: Record = { plane: PlaneBlock, plane_v2: PlaneV2Block, planetscale: PlanetScaleBlock, + planetscale_v2: PlanetScaleV2Block, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ebd5a56b5d7..ebb3c00e192 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -92,6 +92,7 @@ export const CODA_SERVICE_ACCOUNT_PROVIDER_ID = 'coda-service-account' as const export const GAUGE_SERVICE_ACCOUNT_PROVIDER_ID = 'gauge-service-account' as const export const HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID = 'higgsfield-service-account' as const export const THRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'thrive-service-account' as const +export const PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID = 'planetscale-service-account' as const export const SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID = 'supabase-service-account' as const export const INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID = 'infisical-service-account' as const export const OKTA_SERVICE_ACCOUNT_PROVIDER_ID = 'okta-service-account' as const @@ -221,6 +222,7 @@ export type TokenServiceAccountProviderId = | typeof GAUGE_SERVICE_ACCOUNT_PROVIDER_ID | typeof HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID | typeof THRIVE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID | typeof SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID | typeof INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID | typeof OKTA_SERVICE_ACCOUNT_PROVIDER_ID @@ -2340,6 +2342,31 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< invalidCredentialsHelp: 'Thrive rejected this API key. Check that the tenant ID matches the key and that the region host is the one your tenant uses.', }, + [PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'PlanetScale', + tokenNoun: 'service token', + connectNoun: 'service token', + fields: [ + { + id: 'apiToken', + label: 'Service token', + placeholder: 'Paste your PlanetScale service token', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Service token ID', + placeholder: 'Paste the service token ID', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/planetscale', + helpText: + "Create a service token on your PlanetScale organization's Service tokens page, copy its ID and token, and grant it access to the organization and databases your workflows use.", + invalidCredentialsHelp: + 'PlanetScale rejected this service token. Check that the token ID and token belong together and that the token has not been deleted or expired.', + }, } /** diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index e935ef4331c..d6c4321b93e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -81,6 +81,7 @@ import { PERSONA_SERVICE_ACCOUNT_PROVIDER_ID, PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID, PLANE_SERVICE_ACCOUNT_PROVIDER_ID, + PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID, PROFOUND_SERVICE_ACCOUNT_PROVIDER_ID, PULSE_SERVICE_ACCOUNT_PROVIDER_ID, QUARTR_SERVICE_ACCOUNT_PROVIDER_ID, @@ -206,6 +207,7 @@ import { validatePagerDutyServiceAccount, validatePersonaServiceAccount, validatePlaneServiceAccount, + validatePlanetScaleServiceAccount, validateProfoundServiceAccount, validatePulseServiceAccount, validateQuartrServiceAccount, @@ -398,6 +400,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [OKTA_SERVICE_ACCOUNT_PROVIDER_ID]: validateOktaServiceAccount, [PAGERDUTY_SERVICE_ACCOUNT_PROVIDER_ID]: validatePagerDutyServiceAccount, [PLANE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlaneServiceAccount, + [PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlanetScaleServiceAccount, [RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount, [ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount, [SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 0e5ccc3b1bc..9f1a58ace9d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -28,6 +28,7 @@ import { validateNewRelicServiceAccount, validateOktaServiceAccount, validatePlaneServiceAccount, + validatePlanetScaleServiceAccount, validateResendServiceAccount, validateSupabaseServiceAccount, validateThriveServiceAccount, @@ -336,6 +337,8 @@ describe('saved credentials whose connection names a host, region, or account', { domain: 'evil.example.com', providerWorkspaceId: 't1' }, ], ['Twilio', validateTwilioServiceAccount, { providerWorkspaceId: 'AC123/../Messages' }], + ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id:extra' }], + ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id with space' }], ])( '%s refuses a typed value that could steer the request, before sending the key', async (_, validate, typed) => { @@ -449,3 +452,75 @@ describe('saved credentials whose vendor answers a restricted token in its error else await expect(result).resolves.toMatchObject({ principal: null }) }) }) + +describe('PlanetScale saved service token verification', () => { + it('lists organizations with the token ID and token sent as the tools send them', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ type: 'list', data: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'tok-secret', providerWorkspaceId: ' tok-id ' }) + ).resolves.toMatchObject({ + displayName: 'PlanetScale (tok-id)', + normalizedWorkspaceId: 'tok-id', + }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.planetscale.com/v1/organizations', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'tok-id:tok-secret' }), + }) + ) + }) + + it('rejects a token PlanetScale answers with 401', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(jsonResponse({ code: 'unauthorized', message: 'Unauthorized' }, 401)) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'bad', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('accepts a valid token that PlanetScale forbids from this read', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ code: 'forbidden', message: 'Forbidden' }, 403)) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'scoped', providerWorkspaceId: 'tok-id' }) + ).resolves.toMatchObject({ principal: null }) + }) + + it('does not take a proxy 403 page as proof of the token', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('Forbidden', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) + ) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'any', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('refuses a token the tools could not send, before any request', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'tok:extra', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index e4f57a094e4..076d2218ffb 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -36,6 +36,7 @@ import { getMailgunApiBaseUrl } from '@/tools/mailgun/utils' import { getNerdGraphEndpoint, newRelicHeaders } from '@/tools/new_relic/utils' import { oktaHeaders } from '@/tools/okta/utils' import { planeApiUrl, planeHeaders } from '@/tools/plane/utils' +import { PLANETSCALE_API_ORIGIN, planetScaleHeaders } from '@/tools/planetscale/utils' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils' @@ -1130,6 +1131,30 @@ export const validatePlaneServiceAccount = tokenCheckValidator({ }, }) +/** + * Listing the organizations a service token can reach is the read PlanetScale's own docs use, sent + * with the `:` Authorization the tools build (`planetScaleHeaders`). A bad ID or + * token gets 401; a JSON 403 is the vendor refusing a valid token this read. A pair the tools' + * header builder refuses (a colon or whitespace would split it) is refused unsent. + */ +export const validatePlanetScaleServiceAccount = tokenCheckValidator({ + serviceLabel: 'PlanetScale', + workspaceId: ({ providerWorkspaceId }) => (providerWorkspaceId ?? '').trim(), + forbiddenMeansScoped: true, + request: ({ apiToken, providerWorkspaceId = '' }) => { + let headers: Record + try { + headers = planetScaleHeaders({ serviceTokenId: providerWorkspaceId, serviceToken: apiToken }) + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'invalid PlanetScale service token', + }) + } + return { url: `${PLANETSCALE_API_ORIGIN}/v1/organizations`, headers } + }, +}) + /** Profound's organization categories endpoint answers 200 for a valid key and 401 otherwise. */ export const validateProfoundServiceAccount = tokenCheckValidator({ serviceLabel: 'Profound', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2ffd1bd701c..1778eee8aae 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -128,6 +128,7 @@ import { PersonaIcon, PipedriveIcon, PlaneIcon, + PlanetScaleIcon, PowerBIIcon, ProfoundIcon, PulseIcon, @@ -3472,6 +3473,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'thrive', }, + planetscale: { + name: 'PlanetScale', + icon: PlanetScaleIcon, + services: { + planetscale: { + name: 'PlanetScale', + description: 'Manage PlanetScale databases, branches, backups, and deploy requests.', + providerId: 'planetscale', + serviceAccountProviderId: 'planetscale-service-account', + icon: PlanetScaleIcon, + baseProviderIcon: PlanetScaleIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'planetscale', + }, higgsfield: { name: 'Higgsfield', icon: HiggsfieldIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d8221c2ff05..53bba26b811 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -122,6 +122,7 @@ export type OAuthProvider = | 'gauge' | 'higgsfield' | 'thrive' + | 'planetscale' | 'supabase' | 'infisical' | 'okta' @@ -298,6 +299,7 @@ export type OAuthService = | 'gauge' | 'higgsfield' | 'thrive' + | 'planetscale' | 'supabase' | 'infisical' | 'okta' diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 929b4daff1c..6680645f226 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -479,6 +479,31 @@ export const selectorManifest = { detail: true, } ), + 'planetscale.savedDatabases': providerSelector(['organization'], { + readiness: { all: ['oauthCredential', 'organization'] }, + listMode: 'paginated', + search: true, + detail: true, + }), + 'planetscale.savedBranches': providerSelector(['organization', 'database'], { + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: true, + detail: true, + }), + 'planetscale.savedBackups': providerSelector(['organization', 'database', 'branch'], { + sourceFields: { branch: ['branch', 'parentBranch'] }, + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: false, + detail: true, + }), + 'planetscale.savedDeployRequests': providerSelector(['organization', 'database'], { + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: false, + detail: true, + }), 'cloudwatch.logGroups': rawProviderSelector( ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], { diff --git a/apps/sim/lib/selectors/server/providers/planetscale.test.ts b/apps/sim/lib/selectors/server/providers/planetscale.test.ts index 74c1abe6f9c..8f947399469 100644 --- a/apps/sim/lib/selectors/server/providers/planetscale.test.ts +++ b/apps/sim/lib/selectors/server/providers/planetscale.test.ts @@ -1,4 +1,11 @@ +import { + selectorCredentialBundleMock, + selectorCredentialBundleMockFns, +} from '@sim/testing/mocks/selector-credential-bundle.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => selectorCredentialBundleMock) + import { buildSelectorContextFromValues, getSelectorContextSubBlocks, @@ -272,3 +279,45 @@ describe('PlanetScale server selectors', () => { ).rejects.toMatchObject({ name: 'AbortError' }) }) }) + +describe('PlanetScale saved-credential selectors', () => { + const fetchMock = vi.fn() + const resolveBundle = selectorCredentialBundleMockFns.mockResolveSelectorCredentialBundle + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + resolveBundle.mockReset() + }) + + it.each([ + 'planetscale.savedDatabases', + 'planetscale.savedBranches', + 'planetscale.savedBackups', + 'planetscale.savedDeployRequests', + ] as const)('%s lists with the saved token ID and token, never typed ones', async (key) => { + resolveBundle.mockResolvedValue({ + accessToken: 'saved-secret', + providerWorkspaceId: 'saved-id', + }) + fetchMock.mockResolvedValueOnce(page([])) + const input = args({ selectorKey: key }) + + await expect(planetScaleSelectorAttachments[key].execute(input)).resolves.toMatchObject({ + kind: 'list', + }) + expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get('Authorization')).toBe( + 'saved-id:saved-secret' + ) + }) + + it('refuses a saved credential without a token ID before contacting PlanetScale', async () => { + resolveBundle.mockResolvedValue({ accessToken: 'saved-secret' }) + + await expect( + planetScaleSelectorAttachments['planetscale.savedDatabases'].execute( + args({ selectorKey: 'planetscale.savedDatabases' }) + ) + ).rejects.toMatchObject({ name: 'SelectorConnectionUnavailableError' }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/planetscale.ts b/apps/sim/lib/selectors/server/providers/planetscale.ts index aba75b71e93..2ed6880c487 100644 --- a/apps/sim/lib/selectors/server/providers/planetscale.ts +++ b/apps/sim/lib/selectors/server/providers/planetscale.ts @@ -1,19 +1,57 @@ -import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachment, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' import { listPlanetScaleOptions, type PlanetScaleListingKind } from '@/tools/planetscale/listing' +import type { PlanetScaleCredentials } from '@/tools/planetscale/types' -function attachment(kind: PlanetScaleListingKind) { +type PlanetScaleSelectorKey = Extract + +const savedCredential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['planetscale'], +} as const + +/** The service token pair typed into the legacy block; the listing refuses a missing half. */ +function typedCredentials(args: ExecuteServerSelectorArgs): PlanetScaleCredentials { + return { + serviceTokenId: args.context.serviceTokenId ?? '', + serviceToken: args.context.serviceToken ?? '', + } +} + +/** The service token pair of an authorized saved credential: its ID and its token. */ +async function savedCredentials(args: ExecuteServerSelectorArgs): Promise { + const { accessToken, providerWorkspaceId } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!providerWorkspaceId) throw new SelectorConnectionUnavailableError() + return { serviceTokenId: providerWorkspaceId, serviceToken: accessToken } +} + +function attachment( + kind: PlanetScaleListingKind, + credentials: ( + args: ExecuteServerSelectorArgs + ) => PlanetScaleCredentials | Promise, + credential?: typeof savedCredential +): ServerSelectorAttachment { return { + ...(credential ? { credential } : {}), integrationBlockTypes: ['planetscale'], - destination: 'fixed' as const, - execute: ( - args: Parameters[0] - ) => + destination: 'fixed', + execute: async (args) => listPlanetScaleOptions({ kind, scope: { - serviceTokenId: args.context.serviceTokenId!, - serviceToken: args.context.serviceToken!, - organization: args.context.organization!, + ...(await credentials(args)), + organization: args.context.organization ?? '', database: args.context.database, branch: args.context.branch, }, @@ -24,13 +62,16 @@ function attachment(kind: PlanetScaleListingKind) { } export const planetScaleSelectorAttachments = { - 'planetscale.databases': attachment('databases'), - 'planetscale.branches': attachment('branches'), - 'planetscale.backups': attachment('backups'), - 'planetscale.deployRequests': attachment('deployRequests'), -} satisfies ServerSelectorAttachmentMap< - | 'planetscale.databases' - | 'planetscale.branches' - | 'planetscale.backups' - | 'planetscale.deployRequests' -> + 'planetscale.databases': attachment('databases', typedCredentials), + 'planetscale.branches': attachment('branches', typedCredentials), + 'planetscale.backups': attachment('backups', typedCredentials), + 'planetscale.deployRequests': attachment('deployRequests', typedCredentials), + 'planetscale.savedDatabases': attachment('databases', savedCredentials, savedCredential), + 'planetscale.savedBranches': attachment('branches', savedCredentials, savedCredential), + 'planetscale.savedBackups': attachment('backups', savedCredentials, savedCredential), + 'planetscale.savedDeployRequests': attachment( + 'deployRequests', + savedCredentials, + savedCredential + ), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 6dc28653548..1749cc0b123 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -78,6 +78,7 @@ import { OtterBlock, OtterV2Block } from '@/blocks/blocks/otter' import { PagerDutyBlock, PagerDutyV2Block } from '@/blocks/blocks/pagerduty' import { PersonaBlock, PersonaV2Block } from '@/blocks/blocks/persona' import { PlaneBlock, PlaneV2Block } from '@/blocks/blocks/plane' +import { PlanetScaleBlock, PlanetScaleV2Block } from '@/blocks/blocks/planetscale' import { ProfoundBlock, ProfoundV2Block } from '@/blocks/blocks/profound' import { PulseV2Block, PulseV3Block } from '@/blocks/blocks/pulse' import { QuartrBlock, QuartrV2Block } from '@/blocks/blocks/quartr' @@ -361,6 +362,11 @@ const VERSIONS: Array<{ secretIds: ['tenantId', 'apiKey', 'host'], optionalIds: ['host'], }, + { + legacy: PlanetScaleBlock, + successor: PlanetScaleV2Block, + secretIds: ['serviceTokenId', 'serviceToken'], + }, ] function valuesFor(ids: readonly string[], prefix: string): Record { diff --git a/apps/sim/tools/planetscale/close-deploy-request.ts b/apps/sim/tools/planetscale/close-deploy-request.ts index 14b92e96ead..4d705268dac 100644 --- a/apps/sim/tools/planetscale/close-deploy-request.ts +++ b/apps/sim/tools/planetscale/close-deploy-request.ts @@ -12,6 +12,7 @@ import { planetScaleJson, planetScaleNumber, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleCloseDeployRequestTool: ToolConfig< @@ -22,6 +23,7 @@ export const planetScaleCloseDeployRequestTool: ToolConfig< name: 'PlanetScale Close Deploy Request', description: 'Close a deploy request (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/create-backup.ts b/apps/sim/tools/planetscale/create-backup.ts index ce88b0daf85..8fc8fd9e80c 100644 --- a/apps/sim/tools/planetscale/create-backup.ts +++ b/apps/sim/tools/planetscale/create-backup.ts @@ -15,6 +15,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleCreateBackupTool: ToolConfig< @@ -25,6 +26,7 @@ export const planetScaleCreateBackupTool: ToolConfig< name: 'PlanetScale Create Backup', description: 'Create a backup', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/create-branch.ts b/apps/sim/tools/planetscale/create-branch.ts index 009381252d1..bbd2f395c6e 100644 --- a/apps/sim/tools/planetscale/create-branch.ts +++ b/apps/sim/tools/planetscale/create-branch.ts @@ -15,6 +15,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleCreateBranchTool: ToolConfig< @@ -25,6 +26,7 @@ export const planetScaleCreateBranchTool: ToolConfig< name: 'PlanetScale Create Branch', description: 'Create a branch', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/create-deploy-request.ts b/apps/sim/tools/planetscale/create-deploy-request.ts index 3ea787bbeb8..183da6c7d4b 100644 --- a/apps/sim/tools/planetscale/create-deploy-request.ts +++ b/apps/sim/tools/planetscale/create-deploy-request.ts @@ -13,6 +13,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleCreateDeployRequestTool: ToolConfig< @@ -23,6 +24,7 @@ export const planetScaleCreateDeployRequestTool: ToolConfig< name: 'PlanetScale Create Deploy Request', description: 'Create a deploy request (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/delete-branch.ts b/apps/sim/tools/planetscale/delete-branch.ts index 7cb01d182f4..c016b2425bf 100644 --- a/apps/sim/tools/planetscale/delete-branch.ts +++ b/apps/sim/tools/planetscale/delete-branch.ts @@ -7,6 +7,7 @@ import { planetScaleApiUrl, planetScaleHeaders, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleDeleteBranchTool: ToolConfig< @@ -17,6 +18,7 @@ export const planetScaleDeleteBranchTool: ToolConfig< name: 'PlanetScale Delete Branch', description: 'Delete a branch', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/get-backup.ts b/apps/sim/tools/planetscale/get-backup.ts index c850bfa101b..6195935a147 100644 --- a/apps/sim/tools/planetscale/get-backup.ts +++ b/apps/sim/tools/planetscale/get-backup.ts @@ -11,6 +11,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleGetBackupTool: ToolConfig< @@ -21,6 +22,7 @@ export const planetScaleGetBackupTool: ToolConfig< name: 'PlanetScale Get Backup', description: 'Get a backup', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/get-branch.ts b/apps/sim/tools/planetscale/get-branch.ts index 13cc7bb4abf..7760be39286 100644 --- a/apps/sim/tools/planetscale/get-branch.ts +++ b/apps/sim/tools/planetscale/get-branch.ts @@ -11,6 +11,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleGetBranchTool: ToolConfig< @@ -21,6 +22,7 @@ export const planetScaleGetBranchTool: ToolConfig< name: 'PlanetScale Get Branch', description: 'Get a branch', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/get-database.ts b/apps/sim/tools/planetscale/get-database.ts index 73b3c4429a1..7632bbf772d 100644 --- a/apps/sim/tools/planetscale/get-database.ts +++ b/apps/sim/tools/planetscale/get-database.ts @@ -11,6 +11,7 @@ import { planetScaleHeaders, planetScaleJson, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleGetDatabaseTool: ToolConfig< @@ -21,6 +22,7 @@ export const planetScaleGetDatabaseTool: ToolConfig< name: 'PlanetScale Get Database', description: 'Get a database', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/get-deploy-request.ts b/apps/sim/tools/planetscale/get-deploy-request.ts index 221c3c450ca..f6d3b716b41 100644 --- a/apps/sim/tools/planetscale/get-deploy-request.ts +++ b/apps/sim/tools/planetscale/get-deploy-request.ts @@ -12,6 +12,7 @@ import { planetScaleJson, planetScaleNumber, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleGetDeployRequestTool: ToolConfig< @@ -22,6 +23,7 @@ export const planetScaleGetDeployRequestTool: ToolConfig< name: 'PlanetScale Get Deploy Request', description: 'Get a deploy request (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/list-backups.ts b/apps/sim/tools/planetscale/list-backups.ts index 31599b0df4b..871cea13d6d 100644 --- a/apps/sim/tools/planetscale/list-backups.ts +++ b/apps/sim/tools/planetscale/list-backups.ts @@ -19,6 +19,7 @@ import { planetScaleJson, planetScalePaginationSchema, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleListBackupsTool: ToolConfig< @@ -29,6 +30,7 @@ export const planetScaleListBackupsTool: ToolConfig< name: 'PlanetScale List Backups', description: 'List backups', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/list-branches.ts b/apps/sim/tools/planetscale/list-branches.ts index 4b9b1998fba..ef3f31d8aee 100644 --- a/apps/sim/tools/planetscale/list-branches.ts +++ b/apps/sim/tools/planetscale/list-branches.ts @@ -19,6 +19,7 @@ import { planetScaleJson, planetScalePaginationSchema, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleListBranchesTool: ToolConfig< @@ -29,6 +30,7 @@ export const planetScaleListBranchesTool: ToolConfig< name: 'PlanetScale List Branches', description: 'List branches', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/list-databases.ts b/apps/sim/tools/planetscale/list-databases.ts index e3c6089bcb4..c1955325b4d 100644 --- a/apps/sim/tools/planetscale/list-databases.ts +++ b/apps/sim/tools/planetscale/list-databases.ts @@ -17,6 +17,7 @@ import { planetScaleJson, planetScalePaginationSchema, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleListDatabasesTool: ToolConfig< @@ -27,6 +28,7 @@ export const planetScaleListDatabasesTool: ToolConfig< name: 'PlanetScale List Databases', description: 'List databases', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/list-deploy-requests.ts b/apps/sim/tools/planetscale/list-deploy-requests.ts index e17a14f4319..291a75e8364 100644 --- a/apps/sim/tools/planetscale/list-deploy-requests.ts +++ b/apps/sim/tools/planetscale/list-deploy-requests.ts @@ -17,6 +17,7 @@ import { planetScaleJson, planetScalePaginationSchema, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleListDeployRequestsTool: ToolConfig< @@ -30,6 +31,7 @@ export const planetScaleListDeployRequestsTool: ToolConfig< name: 'PlanetScale List Deploy Requests', description: 'List deploy requests (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/queue-deploy-request.ts b/apps/sim/tools/planetscale/queue-deploy-request.ts index 106711befa2..d3fdaf819a6 100644 --- a/apps/sim/tools/planetscale/queue-deploy-request.ts +++ b/apps/sim/tools/planetscale/queue-deploy-request.ts @@ -13,6 +13,7 @@ import { planetScaleJson, planetScaleNumber, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleQueueDeployRequestTool: ToolConfig< @@ -23,6 +24,7 @@ export const planetScaleQueueDeployRequestTool: ToolConfig< name: 'PlanetScale Queue Deploy Request', description: 'Queue a deploy request (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/planetscale/review-deploy-request.ts b/apps/sim/tools/planetscale/review-deploy-request.ts index 529c1d8b708..dfc90091faa 100644 --- a/apps/sim/tools/planetscale/review-deploy-request.ts +++ b/apps/sim/tools/planetscale/review-deploy-request.ts @@ -14,6 +14,7 @@ import { planetScaleNumber, planetScaleReviewSchema, } from '@/tools/planetscale/utils' +import { PLANETSCALE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' import { safeUrlPathSegment } from '@/tools/url-path' export const planetScaleReviewDeployRequestTool: ToolConfig< @@ -24,6 +25,7 @@ export const planetScaleReviewDeployRequestTool: ToolConfig< name: 'PlanetScale Review Deploy Request', description: 'Review a deploy request (Vitess only)', version: '1.0.0', + oauth: PLANETSCALE_CREDENTIAL_OAUTH, params: { serviceTokenId: { type: 'string', diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 16a46b76e23..9f4cc637430 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -247,6 +247,10 @@ export const PLANE_CREDENTIAL_OAUTH = tokenCredentialOAuth('plane', { apiKey: 'accessToken', baseUrl: 'domain', }) +export const PLANETSCALE_CREDENTIAL_OAUTH = tokenCredentialOAuth('planetscale', { + serviceTokenId: 'providerWorkspaceId', + serviceToken: 'accessToken', +}) export const PROFOUND_CREDENTIAL_OAUTH = tokenCredentialOAuth('profound', { apiKey: 'accessToken' }) export const PULSE_CREDENTIAL_OAUTH = tokenCredentialOAuth('pulse', { apiKey: 'accessToken' }) export const QUARTR_CREDENTIAL_OAUTH = tokenCredentialOAuth('quartr', { apiKey: 'accessToken' }) @@ -391,6 +395,7 @@ export const SAVED_CREDENTIAL_OAUTH = { pagerduty: PAGERDUTY_CREDENTIAL_OAUTH, persona: PERSONA_CREDENTIAL_OAUTH, plane: PLANE_CREDENTIAL_OAUTH, + planetscale: PLANETSCALE_CREDENTIAL_OAUTH, profound: PROFOUND_CREDENTIAL_OAUTH, pulse: PULSE_CREDENTIAL_OAUTH, quartr: QUARTR_CREDENTIAL_OAUTH, diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 47e93d1bdbc..40c96633a4d 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1603,7 +1603,6 @@ "apps/sim/lib/selectors/server/internal.ts": 3, "apps/sim/lib/selectors/server/providers/credential-bundle.ts": 1, "apps/sim/lib/selectors/server/providers/planetscale.test.ts": 2, - "apps/sim/lib/selectors/server/providers/planetscale.ts": 3, "apps/sim/lib/selectors/server/providers/slack.ts": 1, "apps/sim/lib/sim-search/connectors.test.ts": 4, "apps/sim/lib/sim-search/live/application.test.ts": 10, From 5f74b849ac0605c67e4c6cb00a1e816bc8a6cf40 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:39:14 -0800 Subject: [PATCH 28/83] feat(credentials): Gong saved access key credential A Gong credential holds an access key and its secret, verified by listing workspaces on api.gong.io with the Basic key:secret the tools send. Gong documents its 403 for this read without telling a bad key from a missing permission, so any refusal rejects. Every Gong tool accepts the credential and gong_v2 is the credential-only successor; the legacy block keeps its inline inputs. --- apps/sim/blocks/blocks/gong.ts | 17 ++++++++ apps/sim/blocks/registry-maps.ts | 3 +- .../token-service-accounts/descriptors.ts | 27 ++++++++++++ .../token-service-accounts/server.ts | 3 ++ .../validators/token-checks.test.ts | 42 +++++++++++++++++++ .../validators/token-checks.ts | 31 ++++++++++++++ apps/sim/lib/oauth/oauth.ts | 19 +++++++++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/serializer/saved-credentials.test.ts | 2 + apps/sim/tools/gong/aggregate_activity.ts | 2 + apps/sim/tools/gong/aggregate_by_period.ts | 2 + apps/sim/tools/gong/answered_scorecards.ts | 2 + apps/sim/tools/gong/ask_anything.ts | 2 + apps/sim/tools/gong/assign_flow_prospects.ts | 2 + apps/sim/tools/gong/create_call.ts | 2 + apps/sim/tools/gong/day_by_day_activity.ts | 2 + apps/sim/tools/gong/get_brief.ts | 2 + apps/sim/tools/gong/get_call.ts | 2 + apps/sim/tools/gong/get_call_transcript.ts | 2 + apps/sim/tools/gong/get_coaching.ts | 2 + apps/sim/tools/gong/get_extensive_calls.ts | 2 + apps/sim/tools/gong/get_folder_content.ts | 2 + apps/sim/tools/gong/get_logs.ts | 2 + apps/sim/tools/gong/get_prospect_flows.ts | 2 + apps/sim/tools/gong/get_user.ts | 2 + apps/sim/tools/gong/interaction_stats.ts | 2 + apps/sim/tools/gong/list_calls.ts | 2 + apps/sim/tools/gong/list_flows.ts | 2 + apps/sim/tools/gong/list_library_folders.ts | 2 + apps/sim/tools/gong/list_scorecards.ts | 2 + apps/sim/tools/gong/list_trackers.ts | 2 + apps/sim/tools/gong/list_users.ts | 2 + apps/sim/tools/gong/list_workspaces.ts | 2 + apps/sim/tools/gong/lookup_email.ts | 2 + apps/sim/tools/gong/lookup_phone.ts | 2 + apps/sim/tools/gong/purge_email_address.ts | 2 + apps/sim/tools/gong/purge_phone_number.ts | 2 + .../sim/tools/gong/unassign_flow_prospects.ts | 2 + apps/sim/tools/shared/saved-credentials.ts | 5 +++ 39 files changed, 208 insertions(+), 1 deletion(-) diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts index 4bb41f893c3..7599830cc87 100644 --- a/apps/sim/blocks/blocks/gong.ts +++ b/apps/sim/blocks/blocks/gong.ts @@ -1,9 +1,12 @@ import { GongIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' import { getTrigger } from '@/triggers' export const GongBlock: BlockConfig = { type: 'gong', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'gong_v2' }, name: 'Gong', description: 'Read Gong data, upload calls, assign or unassign flow prospects, and request data erasure', @@ -1539,3 +1542,17 @@ export const GongBlockMeta = { }, ], } as const satisfies BlockMeta + +export const GongV2Block: BlockConfig = { + ...GongBlock, + type: 'gong_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(GongBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'gong', + title: 'Gong Credential', + }), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 3f42fa19a95..018d1686c87 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -126,7 +126,7 @@ import { } from '@/blocks/blocks/github' import { GitLabBlock, GitLabBlockMeta, GitLabV2Block } from '@/blocks/blocks/gitlab' import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/blocks/blocks/gmail' -import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong' +import { GongBlock, GongBlockMeta, GongV2Block } from '@/blocks/blocks/gong' import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google' import { GoogleAdsBlock, GoogleAdsBlockMeta } from '@/blocks/blocks/google_ads' import { GoogleAppsheetBlock, GoogleAppsheetBlockMeta } from '@/blocks/blocks/google_appsheet' @@ -638,6 +638,7 @@ export const BLOCK_REGISTRY: Record = { gmail: GmailBlock, gmail_v2: GmailV2Block, gong: GongBlock, + gong_v2: GongV2Block, google_ads: GoogleAdsBlock, google_appsheet: GoogleAppsheetBlock, google_bigquery: GoogleBigQueryBlock, diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ebb3c00e192..ff8c65f9633 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -93,6 +93,7 @@ export const GAUGE_SERVICE_ACCOUNT_PROVIDER_ID = 'gauge-service-account' as cons export const HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID = 'higgsfield-service-account' as const export const THRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'thrive-service-account' as const export const PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID = 'planetscale-service-account' as const +export const GONG_SERVICE_ACCOUNT_PROVIDER_ID = 'gong-service-account' as const export const SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID = 'supabase-service-account' as const export const INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID = 'infisical-service-account' as const export const OKTA_SERVICE_ACCOUNT_PROVIDER_ID = 'okta-service-account' as const @@ -223,6 +224,7 @@ export type TokenServiceAccountProviderId = | typeof HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID | typeof THRIVE_SERVICE_ACCOUNT_PROVIDER_ID | typeof PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof GONG_SERVICE_ACCOUNT_PROVIDER_ID | typeof SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID | typeof INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID | typeof OKTA_SERVICE_ACCOUNT_PROVIDER_ID @@ -2367,6 +2369,31 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< invalidCredentialsHelp: 'PlanetScale rejected this service token. Check that the token ID and token belong together and that the token has not been deleted or expired.', }, + [GONG_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: GONG_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Gong', + tokenNoun: 'access key secret', + connectNoun: 'API key', + fields: [ + { + id: 'apiToken', + label: 'Access key secret', + placeholder: 'Paste your Gong access key secret', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Access key', + placeholder: 'Paste your Gong access key', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/gong', + helpText: + 'A Gong technical administrator creates the access key and secret on the API page of the Gong company settings.', + invalidCredentialsHelp: + 'Gong rejected this access key and secret. Check that they were created together, have not been revoked, and that your Gong plan includes API access.', + }, } /** diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index d6c4321b93e..d5166ff9b49 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -46,6 +46,7 @@ import { GAUGE_SERVICE_ACCOUNT_PROVIDER_ID, GITHUB_SERVICE_ACCOUNT_PROVIDER_ID, GITLAB_SERVICE_ACCOUNT_PROVIDER_ID, + GONG_SERVICE_ACCOUNT_PROVIDER_ID, GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID, GRAIN_SERVICE_ACCOUNT_PROVIDER_ID, GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -180,6 +181,7 @@ import { validateGammaServiceAccount, validateGitHubServiceAccount, validateGitLabServiceAccount, + validateGongServiceAccount, validateGrafanaServiceAccount, validateGrainServiceAccount, validateGranolaServiceAccount, @@ -387,6 +389,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [FATHOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateFathomServiceAccount, [GITHUB_SERVICE_ACCOUNT_PROVIDER_ID]: validateGitHubServiceAccount, [GITLAB_SERVICE_ACCOUNT_PROVIDER_ID]: validateGitLabServiceAccount, + [GONG_SERVICE_ACCOUNT_PROVIDER_ID]: validateGongServiceAccount, [GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrafanaServiceAccount, [GRAIN_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrainServiceAccount, [GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGranolaServiceAccount, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 9f1a58ace9d..bcecbedc6f8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -18,6 +18,7 @@ import { validateElevenLabsServiceAccount, validateEmailBisonServiceAccount, validateFlintServiceAccount, + validateGongServiceAccount, validateGrafanaServiceAccount, validateInfisicalServiceAccount, validateInstantlyServiceAccount, @@ -338,6 +339,7 @@ describe('saved credentials whose connection names a host, region, or account', ], ['Twilio', validateTwilioServiceAccount, { providerWorkspaceId: 'AC123/../Messages' }], ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id:extra' }], + ['Gong', validateGongServiceAccount, { providerWorkspaceId: 'key:extra' }], ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id with space' }], ])( '%s refuses a typed value that could steer the request, before sending the key', @@ -524,3 +526,43 @@ describe('PlanetScale saved service token verification', () => { expect(fetch).not.toHaveBeenCalled() }) }) + +describe('Gong saved access key verification', () => { + it('lists workspaces on the tools host with the Basic key and secret the tools send', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ requestId: 'r', workspaces: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateGongServiceAccount({ apiToken: 'key-secret', providerWorkspaceId: ' KEY123 ' }) + ).resolves.toMatchObject({ displayName: 'Gong (KEY123)', normalizedWorkspaceId: 'KEY123' }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.gong.io/v2/workspaces', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: `Basic ${btoa('KEY123:key-secret')}` }), + }) + ) + }) + + it.each([401, 403])('rejects a key pair Gong answers with %i', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ requestId: 'r', errors: ['Access denied'] }, status)) + ) + + await expect( + validateGongServiceAccount({ apiToken: 'bad', providerWorkspaceId: 'KEY123' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('refuses a secret the tools could not encode, before any request', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateGongServiceAccount({ apiToken: 'sécret-€', providerWorkspaceId: 'KEY123' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 076d2218ffb..ac56ee5567c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -680,6 +680,37 @@ export const validateGitLabServiceAccount = tokenCheckValidator({ }, }) +/** + * Listing workspaces is a cheap documented read on the fixed host the tools call, with the same + * Basic `accessKey:accessKeySecret` the tools encode with `btoa`. Gong documents a 403 ("Access + * denied") for this read without telling a bad key from a missing permission, so any refusal + * rejects. The access key is the Basic username, so one with a colon is refused unsent, as is a + * pair `btoa` cannot encode. + */ +export const validateGongServiceAccount = tokenCheckValidator({ + serviceLabel: 'Gong', + workspaceId: ({ providerWorkspaceId }) => { + const accessKey = (providerWorkspaceId ?? '').trim() + if (!/^[\x21-\x39\x3b-\x7e]+$/.test(accessKey)) throw new Error('invalid access key') + return accessKey + }, + request: ({ apiToken, providerWorkspaceId = '' }) => { + let credentials: string + try { + credentials = btoa(`${providerWorkspaceId}:${apiToken}`) + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'invalid Gong access key secret', + }) + } + return { + url: 'https://api.gong.io/v2/workspaces', + headers: { Authorization: `Basic ${credentials}`, Accept: 'application/json' }, + } + }, +}) + /** * Grafana's `GET /api/user` needs a signed-in, non-anonymous identity but no role, so it * answers 200 for any valid service account token and 401 for a bad one, even where diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1778eee8aae..5cd72dc06b5 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -60,6 +60,7 @@ import { GithubIcon, GitLabIcon, GmailIcon, + GongIcon, GoogleAdsIcon, GoogleBigQueryIcon, GoogleCalendarIcon, @@ -3490,6 +3491,24 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'planetscale', }, + gong: { + name: 'Gong', + icon: GongIcon, + services: { + gong: { + name: 'Gong', + description: + 'Read calls, transcripts, users, and activity, and manage Engage flows in Gong.', + providerId: 'gong', + serviceAccountProviderId: 'gong-service-account', + icon: GongIcon, + baseProviderIcon: GongIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'gong', + }, higgsfield: { name: 'Higgsfield', icon: HiggsfieldIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 53bba26b811..0698bb1fd1a 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -123,6 +123,7 @@ export type OAuthProvider = | 'higgsfield' | 'thrive' | 'planetscale' + | 'gong' | 'supabase' | 'infisical' | 'okta' @@ -300,6 +301,7 @@ export type OAuthService = | 'higgsfield' | 'thrive' | 'planetscale' + | 'gong' | 'supabase' | 'infisical' | 'okta' diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 1749cc0b123..96d96f467ec 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -48,6 +48,7 @@ import { FirefliesV2Block, FirefliesV3Block } from '@/blocks/blocks/fireflies' import { FlintBlock, FlintV2Block } from '@/blocks/blocks/flint' import { GammaBlock, GammaV2Block } from '@/blocks/blocks/gamma' import { GitLabBlock, GitLabV2Block } from '@/blocks/blocks/gitlab' +import { GongBlock, GongV2Block } from '@/blocks/blocks/gong' import { GrafanaBlock, GrafanaV2Block } from '@/blocks/blocks/grafana' import { GrainV2Block, GrainV3Block } from '@/blocks/blocks/grain' import { GranolaBlock, GranolaV2Block } from '@/blocks/blocks/granola' @@ -367,6 +368,7 @@ const VERSIONS: Array<{ successor: PlanetScaleV2Block, secretIds: ['serviceTokenId', 'serviceToken'], }, + { legacy: GongBlock, successor: GongV2Block, secretIds: ['accessKey', 'accessKeySecret'] }, ] function valuesFor(ids: readonly string[], prefix: string): Record { diff --git a/apps/sim/tools/gong/aggregate_activity.ts b/apps/sim/tools/gong/aggregate_activity.ts index 66dd10f3432..2ad7be602bc 100644 --- a/apps/sim/tools/gong/aggregate_activity.ts +++ b/apps/sim/tools/gong/aggregate_activity.ts @@ -1,5 +1,6 @@ import type { GongAggregateActivityParams, GongAggregateActivityResponse } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const aggregateActivityTool: ToolConfig< @@ -10,6 +11,7 @@ export const aggregateActivityTool: ToolConfig< name: 'Gong Aggregate Activity', description: 'Retrieve aggregated activity statistics for users by date range from Gong.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/aggregate_by_period.ts b/apps/sim/tools/gong/aggregate_by_period.ts index 3ae39316a3a..fc4a5e2d92c 100644 --- a/apps/sim/tools/gong/aggregate_by_period.ts +++ b/apps/sim/tools/gong/aggregate_by_period.ts @@ -1,5 +1,6 @@ import type { GongAggregateByPeriodParams, GongAggregateByPeriodResponse } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const aggregateByPeriodTool: ToolConfig< @@ -11,6 +12,7 @@ export const aggregateByPeriodTool: ToolConfig< description: 'Retrieve aggregated user activity grouped into time periods (day, week, month, quarter, year) by date range from Gong.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/answered_scorecards.ts b/apps/sim/tools/gong/answered_scorecards.ts index 8b426ebb17a..b11ce0aef03 100644 --- a/apps/sim/tools/gong/answered_scorecards.ts +++ b/apps/sim/tools/gong/answered_scorecards.ts @@ -3,6 +3,7 @@ import type { GongAnsweredScorecardsResponse, } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const answeredScorecardsTool: ToolConfig< @@ -13,6 +14,7 @@ export const answeredScorecardsTool: ToolConfig< name: 'Gong Answered Scorecards', description: 'Retrieve answered scorecards for reviewed users or by date range from Gong.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/ask_anything.ts b/apps/sim/tools/gong/ask_anything.ts index 5d993adedf6..afcd2a3939b 100644 --- a/apps/sim/tools/gong/ask_anything.ts +++ b/apps/sim/tools/gong/ask_anything.ts @@ -1,5 +1,6 @@ import type { GongAskAnythingParams, GongAskAnythingResponse } from '@/tools/gong/types' import { getGongErrorMessage } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const askAnythingTool: ToolConfig = { @@ -8,6 +9,7 @@ export const askAnythingTool: ToolConfig = { @@ -7,6 +8,7 @@ export const createCallTool: ToolConfig = { @@ -8,6 +9,7 @@ export const getBriefTool: ToolConfig description: 'Generate an AI brief (configured in Gong Agent Studio) for a CRM account, deal, contact, or lead. Consumes Gong credits.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/get_call.ts b/apps/sim/tools/gong/get_call.ts index a3011b08ded..57c558139a4 100644 --- a/apps/sim/tools/gong/get_call.ts +++ b/apps/sim/tools/gong/get_call.ts @@ -1,5 +1,6 @@ import type { GongGetCallParams, GongGetCallResponse } from '@/tools/gong/types' import { getGongErrorMessage } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getCallTool: ToolConfig = { @@ -7,6 +8,7 @@ export const getCallTool: ToolConfig = { name: 'Gong Get Call', description: 'Retrieve detailed data for a specific call from Gong.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/get_call_transcript.ts b/apps/sim/tools/gong/get_call_transcript.ts index 531b96354ca..a36a1069c81 100644 --- a/apps/sim/tools/gong/get_call_transcript.ts +++ b/apps/sim/tools/gong/get_call_transcript.ts @@ -1,5 +1,6 @@ import type { GongGetCallTranscriptParams, GongGetCallTranscriptResponse } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getCallTranscriptTool: ToolConfig< @@ -10,6 +11,7 @@ export const getCallTranscriptTool: ToolConfig< name: 'Gong Get Call Transcript', description: 'Retrieve transcripts of calls from Gong by call IDs or date range.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/get_coaching.ts b/apps/sim/tools/gong/get_coaching.ts index dadf32e044f..8c8480415b5 100644 --- a/apps/sim/tools/gong/get_coaching.ts +++ b/apps/sim/tools/gong/get_coaching.ts @@ -6,6 +6,7 @@ import type { GongGetCoachingResponse, } from '@/tools/gong/types' import { getGongErrorMessage } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getCoachingTool: ToolConfig = { @@ -13,6 +14,7 @@ export const getCoachingTool: ToolConfig = { @@ -7,6 +8,7 @@ export const getLogsTool: ToolConfig = { name: 'Gong Get Logs', description: 'Retrieve Gong log entries of a specific type within a time range.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/get_prospect_flows.ts b/apps/sim/tools/gong/get_prospect_flows.ts index 286153e9975..e4563fea25d 100644 --- a/apps/sim/tools/gong/get_prospect_flows.ts +++ b/apps/sim/tools/gong/get_prospect_flows.ts @@ -1,5 +1,6 @@ import type { GongGetProspectFlowsParams, GongGetProspectFlowsResponse } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getProspectFlowsTool: ToolConfig< @@ -10,6 +11,7 @@ export const getProspectFlowsTool: ToolConfig< name: 'Gong Get Prospect Flows', description: 'Get the Gong Engage flows currently assigned to the given CRM prospects.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/get_user.ts b/apps/sim/tools/gong/get_user.ts index d5069a814b9..51dc55c1cb3 100644 --- a/apps/sim/tools/gong/get_user.ts +++ b/apps/sim/tools/gong/get_user.ts @@ -1,5 +1,6 @@ import type { GongGetUserParams, GongGetUserResponse } from '@/tools/gong/types' import { getGongErrorMessage } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getUserTool: ToolConfig = { @@ -7,6 +8,7 @@ export const getUserTool: ToolConfig = { name: 'Gong Get User', description: 'Retrieve details for a specific user from Gong.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/interaction_stats.ts b/apps/sim/tools/gong/interaction_stats.ts index f2aa7421815..c6dc2f920b4 100644 --- a/apps/sim/tools/gong/interaction_stats.ts +++ b/apps/sim/tools/gong/interaction_stats.ts @@ -1,5 +1,6 @@ import type { GongInteractionStatsParams, GongInteractionStatsResponse } from '@/tools/gong/types' import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const interactionStatsTool: ToolConfig< @@ -11,6 +12,7 @@ export const interactionStatsTool: ToolConfig< description: 'Retrieve interaction statistics for users by date range from Gong. Only includes calls with Whisper enabled.', version: '1.0.0', + oauth: GONG_CREDENTIAL_OAUTH, params: { accessKey: { diff --git a/apps/sim/tools/gong/list_calls.ts b/apps/sim/tools/gong/list_calls.ts index 2fbf6982164..a50487f61a8 100644 --- a/apps/sim/tools/gong/list_calls.ts +++ b/apps/sim/tools/gong/list_calls.ts @@ -1,5 +1,6 @@ import type { GongListCallsParams, GongListCallsResponse } from '@/tools/gong/types' import { getGongErrorMessage } from '@/tools/gong/utils' +import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const listCallsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listCallsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listFlowsTool: ToolConfig = @@ -8,6 +9,7 @@ export const listScorecardsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listTrackersTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listUsersTool: ToolConfig = @@ -8,6 +9,7 @@ export const listWorkspacesTool: ToolConfig = { @@ -8,6 +9,7 @@ export const lookupEmailTool: ToolConfig = { @@ -8,6 +9,7 @@ export const lookupPhoneTool: ToolConfig Date: Wed, 11 Nov 2026 13:42:10 -0800 Subject: [PATCH 29/83] feat(credentials): ServiceNow saved username and password credential A ServiceNow credential holds an instance, a username, and its password, verified by reading one user id from the Table API with the Basic auth the tools send. The instance is reduced to the https origin of a ServiceNow-owned host before anything is sent, and the check goes through the guarded egress; 401 rejects, a JSON 403 is a login its ACLs limit, and only a Table API result list proves the login. Every ServiceNow tool accepts the credential, servicenow_v3 is the credential-only successor, and servicenow and servicenow_v2 keep their inline inputs as legacy blocks. --- apps/sim/blocks/blocks/servicenow.ts | 22 ++++- apps/sim/blocks/registry-maps.ts | 8 +- .../token-service-accounts/descriptors.ts | 33 +++++++ .../token-service-accounts/server.ts | 3 + .../validators/token-checks.test.ts | 98 +++++++++++++++++++ .../validators/token-checks.ts | 56 ++++++++++- apps/sim/lib/oauth/oauth.ts | 19 ++++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/serializer/saved-credentials.test.ts | 6 ++ .../tools/servicenow/add_incident_comment.ts | 2 + apps/sim/tools/servicenow/aggregate.ts | 2 + apps/sim/tools/servicenow/close_incident.ts | 2 + .../tools/servicenow/create_change_request.ts | 2 + apps/sim/tools/servicenow/create_incident.ts | 2 + apps/sim/tools/servicenow/create_record.ts | 2 + apps/sim/tools/servicenow/delete_record.ts | 2 + .../tools/servicenow/download_attachment.ts | 2 + apps/sim/tools/servicenow/find_user.ts | 2 + .../servicenow/get_change_next_states.ts | 2 + .../tools/servicenow/get_change_request.ts | 2 + apps/sim/tools/servicenow/get_ci.ts | 2 + apps/sim/tools/servicenow/get_incident.ts | 2 + .../tools/servicenow/get_knowledge_article.ts | 2 + .../tools/servicenow/get_requested_item.ts | 2 + apps/sim/tools/servicenow/list_approvals.ts | 2 + apps/sim/tools/servicenow/list_attachments.ts | 2 + .../tools/servicenow/list_catalog_items.ts | 2 + .../tools/servicenow/list_change_requests.ts | 2 + .../sim/tools/servicenow/list_change_tasks.ts | 2 + .../tools/servicenow/list_ci_relationships.ts | 2 + .../tools/servicenow/list_group_members.ts | 2 + apps/sim/tools/servicenow/list_incidents.ts | 2 + .../tools/servicenow/list_requested_items.ts | 2 + .../tools/servicenow/order_catalog_item.ts | 2 + apps/sim/tools/servicenow/read_record.ts | 2 + apps/sim/tools/servicenow/resolve_incident.ts | 2 + apps/sim/tools/servicenow/search_cis.ts | 2 + apps/sim/tools/servicenow/search_knowledge.ts | 2 + apps/sim/tools/servicenow/update_approval.ts | 2 + .../tools/servicenow/update_change_request.ts | 2 + .../tools/servicenow/update_change_state.ts | 2 + apps/sim/tools/servicenow/update_incident.ts | 2 + apps/sim/tools/servicenow/update_record.ts | 2 + .../sim/tools/servicenow/upload_attachment.ts | 2 + apps/sim/tools/shared/saved-credentials.ts | 6 ++ 45 files changed, 317 insertions(+), 6 deletions(-) diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index b01c3364a8a..5724ad3ae06 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -1,7 +1,7 @@ import { ServiceNowIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput, withSavedCredential } from '@/blocks/utils' import { APPROVAL_DECISION_OPTIONS, APPROVAL_STATE, @@ -165,7 +165,7 @@ export const ServiceNowBlock = { type: 'servicenow', name: 'ServiceNow (Legacy)', hideFromToolbar: true, - sunset: { status: 'legacy', replacedBy: 'servicenow_v2' }, + sunset: { status: 'legacy', replacedBy: 'servicenow_v3' }, description: 'Create, read, update, and delete ServiceNow records', longDescription: 'Create or update incidents and change requests, order catalog items, and process approvals. Read configuration items, knowledge articles, users, and groups, and transfer attachments. Generic record, aggregate, and table-scoped attachment operations accept a table name. Connect with your ServiceNow instance URL, username, and password.', @@ -1928,8 +1928,8 @@ export const ServiceNowV2Block: BlockConfig = { ...ServiceNowBlock, type: 'servicenow_v2', name: 'ServiceNow', - hideFromToolbar: false, - sunset: undefined, + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'servicenow_v3' }, tools: { ...ServiceNowBlock.tools, access: ServiceNowBlock.tools.access.map((toolId) => @@ -1951,6 +1951,20 @@ export const ServiceNowV2Block: BlockConfig = { }, } +export const ServiceNowV3Block: BlockConfig = { + ...ServiceNowV2Block, + type: 'servicenow_v3', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ServiceNowV2Block, { + id: 'credential', + type: 'oauth-input', + serviceId: 'servicenow', + title: 'ServiceNow Credential', + }), +} + export const ServiceNowBlockMeta = { tags: ['customer-support', 'ticketing', 'incident-management'], url: 'https://www.servicenow.com', diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 018d1686c87..892a12adf49 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -381,7 +381,12 @@ import { SendblueBlock, SendblueBlockMeta } from '@/blocks/blocks/sendblue' import { SendGridBlock, SendGridBlockMeta, SendGridV2Block } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryBlockMeta, SentryV2Block } from '@/blocks/blocks/sentry' import { SerperBlock, SerperBlockMeta } from '@/blocks/blocks/serper' -import { ServiceNowBlock, ServiceNowBlockMeta, ServiceNowV2Block } from '@/blocks/blocks/servicenow' +import { + ServiceNowBlock, + ServiceNowBlockMeta, + ServiceNowV2Block, + ServiceNowV3Block, +} from '@/blocks/blocks/servicenow' import { SESBlock, SESBlockMeta, SESV2Block } from '@/blocks/blocks/ses' import { SftpBlock, SftpBlockMeta, SftpV2Block } from '@/blocks/blocks/sftp' import { SharepointBlock, SharepointBlockMeta, SharepointV2Block } from '@/blocks/blocks/sharepoint' @@ -865,6 +870,7 @@ export const BLOCK_REGISTRY: Record = { serper: SerperBlock, servicenow: ServiceNowBlock, servicenow_v2: ServiceNowV2Block, + servicenow_v3: ServiceNowV3Block, ses: SESBlock, ses_v2: SESV2Block, sftp: SftpBlock, diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ff8c65f9633..26ee5bbe227 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -94,6 +94,7 @@ export const HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID = 'higgsfield-service-accoun export const THRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'thrive-service-account' as const export const PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID = 'planetscale-service-account' as const export const GONG_SERVICE_ACCOUNT_PROVIDER_ID = 'gong-service-account' as const +export const SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID = 'servicenow-service-account' as const export const SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID = 'supabase-service-account' as const export const INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID = 'infisical-service-account' as const export const OKTA_SERVICE_ACCOUNT_PROVIDER_ID = 'okta-service-account' as const @@ -225,6 +226,7 @@ export type TokenServiceAccountProviderId = | typeof THRIVE_SERVICE_ACCOUNT_PROVIDER_ID | typeof PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID | typeof GONG_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID | typeof SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID | typeof INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID | typeof OKTA_SERVICE_ACCOUNT_PROVIDER_ID @@ -2394,6 +2396,37 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< invalidCredentialsHelp: 'Gong rejected this access key and secret. Check that they were created together, have not been revoked, and that your Gong plan includes API access.', }, + [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ServiceNow', + tokenNoun: 'password', + connectNoun: 'login', + fields: [ + { + id: 'apiToken', + label: 'Password', + placeholder: 'Enter the ServiceNow user password', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Username', + placeholder: 'Enter the ServiceNow username', + secret: false, + }, + { + id: 'domain', + label: 'Instance URL', + placeholder: 'yourcompany.service-now.com', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/servicenow', + helpText: + 'Use a dedicated integration user with the roles your workflows need and without multi-factor authentication. Enter the instance host on service-now.com, servicenow.com, or servicenowservices.com; custom domains are not supported.', + invalidCredentialsHelp: + 'ServiceNow rejected this username and password. Check them on the instance you entered, and that the user is active, not locked out, and not required to use multi-factor authentication.', + }, } /** diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index d5166ff9b49..fde16c50591 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -95,6 +95,7 @@ import { ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID, SENDGRID_SERVICE_ACCOUNT_PROVIDER_ID, SENTRY_SERVICE_ACCOUNT_PROVIDER_ID, + SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, SIMILARWEB_SERVICE_ACCOUNT_PROVIDER_ID, SIXTYFOUR_SERVICE_ACCOUNT_PROVIDER_ID, @@ -222,6 +223,7 @@ import { validateRootlyServiceAccount, validateSendGridServiceAccount, validateSentryServiceAccount, + validateServiceNowServiceAccount, validateSimilarwebServiceAccount, validateSixtyfourServiceAccount, validateSmartleadServiceAccount, @@ -407,6 +409,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount, [ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount, [SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount, + [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateServiceNowServiceAccount, [STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: validateStripeServiceAccount, [SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSupabaseServiceAccount, [TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTelegramServiceAccount, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index bcecbedc6f8..5136996d167 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -31,6 +31,7 @@ import { validatePlaneServiceAccount, validatePlanetScaleServiceAccount, validateResendServiceAccount, + validateServiceNowServiceAccount, validateSupabaseServiceAccount, validateThriveServiceAccount, validateTinybirdServiceAccount, @@ -340,6 +341,31 @@ describe('saved credentials whose connection names a host, region, or account', ['Twilio', validateTwilioServiceAccount, { providerWorkspaceId: 'AC123/../Messages' }], ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id:extra' }], ['Gong', validateGongServiceAccount, { providerWorkspaceId: 'key:extra' }], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'evil.example.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'acme.service-now.com.evil.example.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'http://acme.service-now.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'https://user:pass@acme.service-now.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'acme.service-now.com', providerWorkspaceId: 'ad:min' }, + ], ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id with space' }], ])( '%s refuses a typed value that could steer the request, before sending the key', @@ -566,3 +592,75 @@ describe('Gong saved access key verification', () => { expect(fetch).not.toHaveBeenCalled() }) }) + +describe('ServiceNow saved username and password verification', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + function reply(response: Response) { + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'acme.service-now.com', + }) + secureFetch.mockReset().mockResolvedValue(response) + } + + it('reads one user on the instance origin with the Basic auth the tools send', async () => { + reply(jsonResponse({ result: [{ sys_id: 'abc' }] })) + + await expect( + validateServiceNowServiceAccount({ + apiToken: 'pass', + providerWorkspaceId: ' integration.user ', + domain: 'acme.service-now.com/nav_to.do?uri=incident.do', + }) + ).resolves.toMatchObject({ + displayName: 'ServiceNow (https://acme.service-now.com)', + normalizedDomain: 'https://acme.service-now.com', + normalizedWorkspaceId: 'integration.user', + }) + expect(secureFetch).toHaveBeenCalledWith( + 'https://acme.service-now.com/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id', + '203.0.113.7', + expect.objectContaining({ + method: 'GET', + maxRedirects: 0, + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('integration.user:pass').toString('base64')}`, + }), + }) + ) + }) + + it.each([ + [ + 'rejects a password ServiceNow answers with 401', + jsonResponse({ error: {} }, 401), + 'invalid_credentials', + ], + [ + 'accepts a user whose ACLs refuse the user table', + jsonResponse({ error: { message: 'User Not Authorized' }, status: 'failure' }, 403), + null, + ], + [ + 'does not take a hibernating instance page as proof', + new Response('Instance Hibernating', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }), + 'provider_unavailable', + ], + ] as const)('%s', async (_, response, code) => { + reply(response) + + const result = validateServiceNowServiceAccount({ + apiToken: 'pass', + providerWorkspaceId: 'admin', + domain: 'https://acme.service-now.com', + }) + + if (code) await expect(result).rejects.toMatchObject({ code }) + else await expect(result).resolves.toMatchObject({ principal: null }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index ac56ee5567c..33d82373685 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -1,5 +1,9 @@ import { toArray, toRecord } from '@sim/utils/object' -import { validateOktaDomain, validateSupabaseProjectId } from '@/lib/core/security/input-validation' +import { + validateOktaDomain, + validateServiceNowInstanceUrl, + validateSupabaseProjectId, +} from '@/lib/core/security/input-validation' import { fetchProvider, TokenServiceAccountValidationError, @@ -37,6 +41,7 @@ import { getNerdGraphEndpoint, newRelicHeaders } from '@/tools/new_relic/utils' import { oktaHeaders } from '@/tools/okta/utils' import { planeApiUrl, planeHeaders } from '@/tools/plane/utils' import { PLANETSCALE_API_ORIGIN, planetScaleHeaders } from '@/tools/planetscale/utils' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils' @@ -1309,6 +1314,55 @@ export const validateSentryServiceAccount = tokenCheckValidator({ }), }) +/** + * A typed ServiceNow instance reduced to the https origin the tools build `/api/now/...` paths on: + * a bare host is taken as https, a pasted console path or query is dropped, and the host must be + * one of ServiceNow's own domains (`validateServiceNowInstanceUrl`), so a saved password can only + * reach a ServiceNow instance. + */ +function normalizeServiceNowInstanceUrl(raw: string | undefined): string { + const typed = refuseUnsafeTypedUrl(raw) + const candidate = /^https?:\/\//i.test(typed) ? typed : `https://${typed}` + const result = validateServiceNowInstanceUrl(candidate) + if (!result.isValid) throw new Error('not a ServiceNow instance URL') + return new URL(result.sanitized ?? candidate).origin +} + +/** + * Reading one user id from the Table API is a cheap documented read with the Basic + * `username:password` the tools send, on the instance it was typed with. ServiceNow answers bad + * credentials with 401 and a request its ACLs refuse with a JSON 403, which still proves the + * login. The username is the Basic username, so one with a colon is refused unsent. Only a + * Table API `result` list proves the login: a hibernating instance answers 200 with a page. + */ +export const validateServiceNowServiceAccount = tokenCheckValidator({ + serviceLabel: 'ServiceNow', + domain: ({ domain }) => normalizeServiceNowInstanceUrl(domain), + workspaceId: ({ providerWorkspaceId }) => { + const username = (providerWorkspaceId ?? '').trim() + if (!/^[^:\x00-\x1f\x7f]+$/.test(username)) throw new Error('invalid username') + return username + }, + egress: 'configuredEndpoint', + forbiddenMeansScoped: true, + request: ({ apiToken, domain, providerWorkspaceId = '' }) => ({ + url: `${domain}/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id`, + headers: { + Authorization: createBasicAuthHeader(providerWorkspaceId, apiToken), + Accept: 'application/json', + }, + }), + acceptsBody: (body) => { + if (!Array.isArray(toRecord(body).result)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'ServiceNow returned an unexpected table read', + }) + } + return true + }, +}) + /** Similarweb's capabilities endpoint costs no data credits and answers 200 only for a valid key. */ export const validateSimilarwebServiceAccount = tokenCheckValidator({ serviceLabel: 'Similarweb', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5cd72dc06b5..d2b2ae84271 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -147,6 +147,7 @@ import { SalesforceIcon, SendgridIcon, SentryIcon, + ServiceNowIcon, ShopifyIcon, SimilarwebIcon, SixtyfourIcon, @@ -3509,6 +3510,24 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'gong', }, + servicenow: { + name: 'ServiceNow', + icon: ServiceNowIcon, + services: { + servicenow: { + name: 'ServiceNow', + description: + 'Work with incidents, change requests, catalog items, and records in ServiceNow.', + providerId: 'servicenow', + serviceAccountProviderId: 'servicenow-service-account', + icon: ServiceNowIcon, + baseProviderIcon: ServiceNowIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'servicenow', + }, higgsfield: { name: 'Higgsfield', icon: HiggsfieldIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 0698bb1fd1a..dcff67e4753 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -124,6 +124,7 @@ export type OAuthProvider = | 'thrive' | 'planetscale' | 'gong' + | 'servicenow' | 'supabase' | 'infisical' | 'okta' @@ -302,6 +303,7 @@ export type OAuthService = | 'thrive' | 'planetscale' | 'gong' + | 'servicenow' | 'supabase' | 'infisical' | 'okta' diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 96d96f467ec..85b7d786ed9 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -95,6 +95,7 @@ import { S3Block, S3V2Block } from '@/blocks/blocks/s3' import { SecretsManagerBlock, SecretsManagerV2Block } from '@/blocks/blocks/secrets_manager' import { SendGridBlock, SendGridV2Block } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryV2Block } from '@/blocks/blocks/sentry' +import { ServiceNowV2Block, ServiceNowV3Block } from '@/blocks/blocks/servicenow' import { SESBlock, SESV2Block } from '@/blocks/blocks/ses' import { SimilarwebBlock, SimilarwebV2Block } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourV2Block } from '@/blocks/blocks/sixtyfour' @@ -369,6 +370,11 @@ const VERSIONS: Array<{ secretIds: ['serviceTokenId', 'serviceToken'], }, { legacy: GongBlock, successor: GongV2Block, secretIds: ['accessKey', 'accessKeySecret'] }, + { + legacy: ServiceNowV2Block, + successor: ServiceNowV3Block, + secretIds: ['instanceUrl', 'username', 'password'], + }, ] function valuesFor(ids: readonly string[], prefix: string): Record { diff --git a/apps/sim/tools/servicenow/add_incident_comment.ts b/apps/sim/tools/servicenow/add_incident_comment.ts index 7c196503608..59fa8bfbb71 100644 --- a/apps/sim/tools/servicenow/add_incident_comment.ts +++ b/apps/sim/tools/servicenow/add_incident_comment.ts @@ -14,6 +14,7 @@ import { buildTableRecordUrl, transformRecordResponse, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const addIncidentCommentTool: ToolConfig< @@ -25,6 +26,7 @@ export const addIncidentCommentTool: ToolConfig< description: 'Append an internal work note or a customer-visible additional comment to a ServiceNow incident. Both are journal fields, so the text is appended rather than replacing earlier entries.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/aggregate.ts b/apps/sim/tools/servicenow/aggregate.ts index 761af19d80e..f8377e01958 100644 --- a/apps/sim/tools/servicenow/aggregate.ts +++ b/apps/sim/tools/servicenow/aggregate.ts @@ -10,6 +10,7 @@ import { toRecordArray, toRecordObject, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowAggregateTool') @@ -20,6 +21,7 @@ export const aggregateTool: ToolConfig = { @@ -20,6 +21,7 @@ export const findUserTool: ToolConfig = { @@ -16,6 +17,7 @@ export const getCiTool: ToolConfig".', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/order_catalog_item.ts b/apps/sim/tools/servicenow/order_catalog_item.ts index a13fc96880d..12f7e97b5c9 100644 --- a/apps/sim/tools/servicenow/order_catalog_item.ts +++ b/apps/sim/tools/servicenow/order_catalog_item.ts @@ -10,6 +10,7 @@ import { readString, toRecordObject, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const orderCatalogItemTool: ToolConfig< @@ -21,6 +22,7 @@ export const orderCatalogItemTool: ToolConfig< description: 'Submit a service catalog request for a catalog item using the Service Catalog API order_now endpoint. Returns the generated request number and sys_id.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/read_record.ts b/apps/sim/tools/servicenow/read_record.ts index e08a3bfbb83..41397d8a47e 100644 --- a/apps/sim/tools/servicenow/read_record.ts +++ b/apps/sim/tools/servicenow/read_record.ts @@ -6,6 +6,7 @@ import { parseServiceNowResponse, toRecordArray, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowReadRecordTool') @@ -15,6 +16,7 @@ export const readRecordTool: ToolConfig = { @@ -20,6 +21,7 @@ export const searchCisTool: ToolConfig([APPROVAL_STATE.APPROVED, APPROVAL_STATE.REJECTED]) @@ -23,6 +24,7 @@ export const updateApprovalTool: ToolConfig< description: 'Approve or reject a ServiceNow approval record by setting its state on the Approval [sysapproval_approver] table.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/update_change_request.ts b/apps/sim/tools/servicenow/update_change_request.ts index e2a632b6b47..0d5243a1955 100644 --- a/apps/sim/tools/servicenow/update_change_request.ts +++ b/apps/sim/tools/servicenow/update_change_request.ts @@ -16,6 +16,7 @@ import { buildTableRecordUrl, transformRecordResponse, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const updateChangeRequestTool: ToolConfig< @@ -27,6 +28,7 @@ export const updateChangeRequestTool: ToolConfig< description: 'Update fields on an existing ServiceNow change request. Only the fields you supply are changed. Use Move ServiceNow Change State for state transitions.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/update_change_state.ts b/apps/sim/tools/servicenow/update_change_state.ts index c072be1dc9f..303b632ead4 100644 --- a/apps/sim/tools/servicenow/update_change_state.ts +++ b/apps/sim/tools/servicenow/update_change_state.ts @@ -16,6 +16,7 @@ import { buildTableRecordUrl, transformRecordResponse, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const updateChangeStateTool: ToolConfig< @@ -27,6 +28,7 @@ export const updateChangeStateTool: ToolConfig< description: 'Move a ServiceNow change request to another state. Base-system change model states are -5=New, -4=Assess, -3=Authorize, -2=Scheduled, -1=Implement, 0=Review, 3=Closed, 4=Canceled. The state machine rejects transitions whose conditions are not met.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/update_incident.ts b/apps/sim/tools/servicenow/update_incident.ts index 7f26d469633..f3f827f1182 100644 --- a/apps/sim/tools/servicenow/update_incident.ts +++ b/apps/sim/tools/servicenow/update_incident.ts @@ -16,6 +16,7 @@ import { buildTableRecordUrl, transformRecordResponse, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const updateIncidentTool: ToolConfig< @@ -27,6 +28,7 @@ export const updateIncidentTool: ToolConfig< description: 'Update fields on an existing ServiceNow incident. Only the fields you supply are changed. Reference fields take sys_ids unless input display value is enabled.', version: '1.0.0', + oauth: SERVICENOW_CREDENTIAL_OAUTH, params: { ...authParams, diff --git a/apps/sim/tools/servicenow/update_record.ts b/apps/sim/tools/servicenow/update_record.ts index d34b43c587b..21795a02571 100644 --- a/apps/sim/tools/servicenow/update_record.ts +++ b/apps/sim/tools/servicenow/update_record.ts @@ -6,6 +6,7 @@ import { parseServiceNowResponse, toRecordObject, } from '@/tools/servicenow/utils' +import { SERVICENOW_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowUpdateRecordTool') @@ -15,6 +16,7 @@ export const updateRecordTool: ToolConfig Date: Wed, 11 Nov 2026 13:45:17 -0800 Subject: [PATCH 30/83] feat(credentials): Twilio API key mode for the saved Twilio credential The Twilio credential and every Twilio tool now also take an API key, as the key SID and secret joined by a colon (SKxxx:secret), the exact Basic username and password Twilio documents for a key, while the URL keeps the Account SID. A key is verified by listing one of the account's phone numbers, a read a Standard key may make, since only Main keys and auth tokens may read the Accounts resource. Stored auth-token credentials and inline auth tokens keep their shape and their account-resource check; a value with a colon that is not a key pair is refused before any request. --- .../token-service-accounts/descriptors.ts | 8 +-- .../validators/token-checks.test.ts | 70 +++++++++++++++++++ .../validators/token-checks.ts | 36 +++++++--- .../internal/twilio-voice/operations.test.ts | 16 +++++ .../lib/internal/twilio-voice/operations.ts | 3 +- apps/sim/tools/twilio/send_sms.ts | 7 +- apps/sim/tools/twilio/utils.test.ts | 69 ++++++++++++++++++ apps/sim/tools/twilio/utils.ts | 21 ++++++ apps/sim/tools/twilio_voice/get_recording.ts | 2 +- apps/sim/tools/twilio_voice/list_calls.ts | 10 +-- apps/sim/tools/twilio_voice/make_call.ts | 6 +- 11 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 apps/sim/tools/twilio/utils.test.ts create mode 100644 apps/sim/tools/twilio/utils.ts diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 26ee5bbe227..3226df93a76 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -2142,8 +2142,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< fields: [ { id: 'apiToken', - label: 'Auth token', - placeholder: 'Paste your Twilio auth token', + label: 'Auth token or API key', + placeholder: 'Paste your auth token, or SKxxx:secret for an API key', secret: true, }, { @@ -2157,9 +2157,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/twilio_sms', helpText: - 'Copy the Account SID and Auth Token from Account Info on your Twilio Console dashboard.', + 'Copy the Account SID and Auth Token from Account Info on your Twilio Console dashboard. To use an API key instead, enter its SID and secret joined by a colon (SKxxx:secret); a Standard or Main key works, and a restricted key needs read access to phone numbers.', invalidCredentialsHelp: - 'Twilio rejected this Account SID and auth token. Check that the token belongs to this account and has not been rotated.', + 'Twilio rejected this Account SID and auth token or API key. Check that the token or key belongs to this account and has not been rotated or deleted, and that an API key is entered as SKxxx:secret.', }, [ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 5136996d167..5bd4d974849 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -664,3 +664,73 @@ describe('ServiceNow saved username and password verification', () => { else await expect(result).resolves.toMatchObject({ principal: null }) }) }) + +describe('Twilio saved credential verification', () => { + const ACCOUNT_SID = `AC${'a'.repeat(32)}` + const API_KEY_SID = `SK${'b'.repeat(32)}` + + it('checks an auth token against the account resource, as before', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ sid: ACCOUNT_SID })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ apiToken: 'c'.repeat(32), providerWorkspaceId: ACCOUNT_SID }) + ).resolves.toMatchObject({ displayName: `Twilio (${ACCOUNT_SID})` }) + expect(fetch).toHaveBeenCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}.json`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from(`${ACCOUNT_SID}:${'c'.repeat(32)}`).toString('base64')}`, + }), + }) + ) + }) + + it('checks an API key with a read a Standard key may make, sent as the key SID and secret', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ incoming_phone_numbers: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ + apiToken: `${API_KEY_SID}:key-secret`, + providerWorkspaceId: ACCOUNT_SID, + }) + ).resolves.toMatchObject({ displayName: `Twilio (${ACCOUNT_SID})` }) + expect(fetch).toHaveBeenCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}/IncomingPhoneNumbers.json?PageSize=1`, + expect.objectContaining({ + redirect: 'error', + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from(`${API_KEY_SID}:key-secret`).toString('base64')}`, + }), + }) + ) + }) + + it('rejects an API key Twilio refuses for this account', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ code: 20003, message: 'Authenticate' }, 401)) + ) + + await expect( + validateTwilioServiceAccount({ + apiToken: `${API_KEY_SID}:wrong`, + providerWorkspaceId: ACCOUNT_SID, + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it.each([`SKshort:secret`, `${API_KEY_SID}:`, `${ACCOUNT_SID}:secret`, 'token:with:colons'])( + 'refuses %s, which is neither an auth token nor an API key, before any request', + async (apiToken) => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ apiToken, providerWorkspaceId: ACCOUNT_SID }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 33d82373685..aa4f7ba9ef9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -44,6 +44,7 @@ import { PLANETSCALE_API_ORIGIN, planetScaleHeaders } from '@/tools/planetscale/ import { createBasicAuthHeader } from '@/tools/servicenow/utils' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' +import { isTwilioApiKeyPair, twilioAuthorization } from '@/tools/twilio/utils' import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils' /** @@ -1535,9 +1536,14 @@ export const validateTriggerDevServiceAccount = tokenCheckValidator({ const TWILIO_ACCOUNT_SID = /^AC[0-9a-f]{32}$/ /** - * Fetching the account resource with the SID and auth token as Basic auth (the tools' scheme) - * answers 200 for a matching pair and 401 (code 20003) for a wrong token, an unknown SID, or a SID - * the token doesn't belong to. The SID is checked first so it can never rewrite the path. + * The credential holds the account SID plus either its auth token or an API key pair + * (`SKxxx:secret`, sent as Twilio's Basic username and password for a key). An auth token fetches + * the account resource (the tools' scheme, `accountSid:authToken`): 200 for a matching pair, 401 + * (code 20003) for a wrong token, an unknown SID, or a SID the token doesn't belong to. A Standard + * API key may not read the Accounts resource, so a key lists one of the account's phone numbers + * instead; Twilio refuses that read for a wrong secret or a key of another account. The SID is + * checked first so it can never rewrite the path, and a value with a colon that isn't a key pair + * is refused unsent. */ export const validateTwilioServiceAccount = tokenCheckValidator({ serviceLabel: 'Twilio', @@ -1546,13 +1552,23 @@ export const validateTwilioServiceAccount = tokenCheckValidator({ if (!TWILIO_ACCOUNT_SID.test(accountSid)) throw new Error('invalid Account SID') return accountSid }, - request: ({ apiToken, providerWorkspaceId }) => ({ - url: `https://api.twilio.com/2010-04-01/Accounts/${providerWorkspaceId}.json`, - headers: { - Authorization: `Basic ${Buffer.from(`${providerWorkspaceId}:${apiToken}`).toString('base64')}`, - Accept: 'application/json', - }, - }), + request: ({ apiToken, providerWorkspaceId = '' }) => { + const apiKey = isTwilioApiKeyPair(apiToken) + if (!apiKey && apiToken.includes(':')) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'neither an auth token nor an API key pair', + }) + } + const account = `https://api.twilio.com/2010-04-01/Accounts/${providerWorkspaceId}` + return { + url: apiKey ? `${account}/IncomingPhoneNumbers.json?PageSize=1` : `${account}.json`, + headers: { + Authorization: twilioAuthorization(providerWorkspaceId, apiToken), + Accept: 'application/json', + }, + } + }, }) /** diff --git a/apps/sim/lib/internal/twilio-voice/operations.test.ts b/apps/sim/lib/internal/twilio-voice/operations.test.ts index bfe505fd4d7..abd43ee52a2 100644 --- a/apps/sim/lib/internal/twilio-voice/operations.test.ts +++ b/apps/sim/lib/internal/twilio-voice/operations.test.ts @@ -73,4 +73,20 @@ describe('getTwilioRecording', () => { output: { duration: 42, transcriptionText: 'hello', file: storedFile }, }) }) + it('authenticates every request with an API key as its SID and secret', async () => { + const apiKey = `SK${'b'.repeat(32)}:key-secret` + await getTwilioRecording( + { accountSid: 'AC123', authToken: apiKey, recordingSid: 'RE123' }, + { requestId: 'request-1' } + ) + + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3) + for (const [, , init] of mockSecureFetchWithPinnedIP.mock.calls) { + expect(init.headers).toEqual( + expect.objectContaining({ + Authorization: `Basic ${Buffer.from(apiKey).toString('base64')}`, + }) + ) + } + }) }) diff --git a/apps/sim/lib/internal/twilio-voice/operations.ts b/apps/sim/lib/internal/twilio-voice/operations.ts index 647b39f009e..07749f689b2 100644 --- a/apps/sim/lib/internal/twilio-voice/operations.ts +++ b/apps/sim/lib/internal/twilio-voice/operations.ts @@ -15,6 +15,7 @@ import { import { TwilioVoiceOperationError } from '@/lib/internal/twilio-voice/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file' +import { twilioAuthorization } from '@/tools/twilio/utils' import type { TwilioGetRecordingParams } from '@/tools/twilio_voice/types' const logger = createLogger('TwilioGetRecordingOperation') @@ -79,7 +80,7 @@ export async function getTwilioRecording( 400 ) } - const authHeader = `Basic ${Buffer.from(`${input.accountSid}:${input.authToken}`).toString('base64')}` + const authHeader = twilioAuthorization(input.accountSid, input.authToken) const infoUrl = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(input.accountSid)}/Recordings/${encodeURIComponent(input.recordingSid)}.json` const infoResponse = await fetchPinned( infoUrl, diff --git a/apps/sim/tools/twilio/send_sms.ts b/apps/sim/tools/twilio/send_sms.ts index ca34cb624ba..c8fc830fb01 100644 --- a/apps/sim/tools/twilio/send_sms.ts +++ b/apps/sim/tools/twilio/send_sms.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { TWILIO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { TwilioSendSMSParams, TwilioSMSBlockOutput } from '@/tools/twilio/types' +import { twilioAuthorization } from '@/tools/twilio/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('Twilio Send SMS Tool') @@ -36,7 +37,7 @@ export const sendSMSTool: ToolConfig type: 'string', required: true, visibility: 'user-only', - description: 'Twilio Auth Token', + description: 'Twilio Auth Token, or an API key as SKxxx:secret', }, fromNumber: { type: 'string', @@ -60,10 +61,8 @@ export const sendSMSTool: ToolConfig if (!params.accountSid || !params.authToken) { throw new Error('Twilio credentials are required') } - // Use Buffer instead of btoa for Node.js compatibility - const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') const headers = { - Authorization: `Basic ${authToken}`, + Authorization: twilioAuthorization(params.accountSid, params.authToken), 'Content-Type': 'application/x-www-form-urlencoded', } return headers diff --git a/apps/sim/tools/twilio/utils.test.ts b/apps/sim/tools/twilio/utils.test.ts new file mode 100644 index 00000000000..820caaca0bc --- /dev/null +++ b/apps/sim/tools/twilio/utils.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { sendSMSTool } from '@/tools/twilio/send_sms' +import { listCallsTool } from '@/tools/twilio_voice/list_calls' +import { makeCallTool } from '@/tools/twilio_voice/make_call' + +const ACCOUNT_SID = `AC${'a'.repeat(32)}` +const AUTH_TOKEN = 'c'.repeat(32) +const API_KEY_PAIR = `SK${'b'.repeat(32)}:key-secret` + +function basic(pair: string): string { + return `Basic ${Buffer.from(pair).toString('base64')}` +} + +function urlFor

(url: string | ((params: P) => string), params: P): string { + return typeof url === 'function' ? url(params) : url +} + +/** The Authorization header and URL each Twilio request tool builds for one credential value. */ +function requestsWith(authToken: string): Array<[string, string, string]> { + const sms = { + phoneNumbers: '+15550000001', + message: 'hello', + fromNumber: '+15550000000', + accountSid: ACCOUNT_SID, + authToken, + } + const call = { + to: '+15550000001', + from: '+15550000000', + twiml: '', + accountSid: ACCOUNT_SID, + authToken, + } + const list = { accountSid: ACCOUNT_SID, authToken } + return [ + [ + 'send SMS', + sendSMSTool.request.headers(sms).Authorization, + urlFor(sendSMSTool.request.url, sms), + ], + [ + 'make call', + makeCallTool.request.headers(call).Authorization, + urlFor(makeCallTool.request.url, call), + ], + [ + 'list calls', + listCallsTool.request.headers(list).Authorization, + urlFor(listCallsTool.request.url, list), + ], + ] +} + +describe('Twilio request authorization', () => { + it.each(requestsWith(AUTH_TOKEN))( + '%s authenticates an auth token as the account, as before', + (_, authorization) => { + expect(authorization).toBe(basic(`${ACCOUNT_SID}:${AUTH_TOKEN}`)) + } + ) + + it.each(requestsWith(API_KEY_PAIR))( + '%s authenticates an API key as its SID and secret, on the account URL', + (_, authorization, url) => { + expect(authorization).toBe(basic(API_KEY_PAIR)) + expect(url).toContain(`/Accounts/${ACCOUNT_SID}/`) + } + ) +}) diff --git a/apps/sim/tools/twilio/utils.ts b/apps/sim/tools/twilio/utils.ts new file mode 100644 index 00000000000..713d650250d --- /dev/null +++ b/apps/sim/tools/twilio/utils.ts @@ -0,0 +1,21 @@ +/** + * A Twilio API key and its secret as one value, `SK<32 hex>:`: the exact username and + * password Twilio's HTTP Basic auth takes for an API key. An auth token is 32 hex characters + * and never contains a colon, so the two can't be confused. + */ +const TWILIO_API_KEY_PAIR = /^SK[0-9a-fA-F]{32}:\S+$/ + +/** Whether a Twilio credential value is an API key pair rather than an account auth token. */ +export function isTwilioApiKeyPair(authToken: string): boolean { + return TWILIO_API_KEY_PAIR.test(authToken) +} + +/** + * The Basic Authorization header for a Twilio REST request on `accountSid`'s URLs. An auth token + * authenticates as the account itself (`accountSid:authToken`); an API key pair authenticates as + * the key (`SKxxx:secret`), while the URL still names the account. + */ +export function twilioAuthorization(accountSid: string, authToken: string): string { + const credentials = isTwilioApiKeyPair(authToken) ? authToken : `${accountSid}:${authToken}` + return `Basic ${Buffer.from(credentials).toString('base64')}` +} diff --git a/apps/sim/tools/twilio_voice/get_recording.ts b/apps/sim/tools/twilio_voice/get_recording.ts index 74a42d2b3c3..4a56a5fcdf7 100644 --- a/apps/sim/tools/twilio_voice/get_recording.ts +++ b/apps/sim/tools/twilio_voice/get_recording.ts @@ -29,7 +29,7 @@ export const getRecordingTool: InternalToolConfig< type: 'string', required: true, visibility: 'user-only', - description: 'Twilio Auth Token', + description: 'Twilio Auth Token, or an API key as SKxxx:secret', }, }, diff --git a/apps/sim/tools/twilio_voice/list_calls.ts b/apps/sim/tools/twilio_voice/list_calls.ts index b1a6db6f353..63862712e48 100644 --- a/apps/sim/tools/twilio_voice/list_calls.ts +++ b/apps/sim/tools/twilio_voice/list_calls.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { TWILIO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' +import { twilioAuthorization } from '@/tools/twilio/utils' import type { TwilioListCallsOutput, TwilioListCallsParams } from '@/tools/twilio_voice/types' import type { ToolConfig, ToolResponseContext } from '@/tools/types' @@ -56,7 +57,7 @@ export const listCallsTool: ToolConfig = type: 'string', required: true, visibility: 'user-only', - description: 'Twilio Auth Token', + description: 'Twilio Auth Token, or an API key as SKxxx:secret', }, record: { type: 'boolean', @@ -107,9 +108,8 @@ export const makeCallTool: ToolConfig = if (!params.accountSid || !params.authToken) { throw new Error('Twilio credentials are required') } - const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') return { - Authorization: `Basic ${authToken}`, + Authorization: twilioAuthorization(params.accountSid, params.authToken), 'Content-Type': 'application/x-www-form-urlencoded', } }, From e1b007758a69ae15abea8f6efa6b5b1758c57017 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:47:33 -0800 Subject: [PATCH 31/83] fix(credentials): retry only the documented Entra propagation refusal, within the shared mint deadline AADSTS700213 is Microsoft's documented case-sensitive federated credential mismatch, not a propagation refusal, so a mismatched keyless credential no longer makes three token requests per run. The AADSTS70021 retries now stop when a slow answer would push the run past the 30 s deadline that concurrent runs sharing the mint wait for. The Microsoft app service list is declared once for both its type and the resolver's check, and the Intune and Vertex copy names the app credential and the Google service account. --- .../docs/content/docs/integrations/intune.mdx | 2 +- apps/sim/blocks/blocks/agent.ts | 6 ++- apps/sim/blocks/blocks/intune.ts | 2 +- apps/sim/blocks/utils.ts | 5 +- ...crosoft-app-service-account.server.test.ts | 52 +++++++++++++------ .../microsoft-app-service-account.server.ts | 51 +++++++++++------- apps/sim/tools/llm/chat.ts | 3 +- 7 files changed, 81 insertions(+), 40 deletions(-) diff --git a/apps/docs/content/docs/integrations/intune.mdx b/apps/docs/content/docs/integrations/intune.mdx index 0a45f6492f1..fa44bc119c7 100644 --- a/apps/docs/content/docs/integrations/intune.mdx +++ b/apps/docs/content/docs/integrations/intune.mdx @@ -12,7 +12,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Microsoft Intune](https://www.microsoft.com/en-us/security/business/microsoft-intune) to inspect managed devices, detected applications, compliance policy statuses, and device configuration metadata. Request device sync, restart, remote lock, or retirement; restart, lock, and retirement require explicit confirmation. -Connect a Microsoft work or school account with an appropriate Intune role. The tenant needs an active Intune license and administrator consent for the delegated permissions below. Self-hosted deployments require `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, and the matching `/api/auth/oauth2/callback/microsoft-intune` callback. +Connect a Microsoft work or school account with an appropriate Intune role. The tenant needs an active Intune license and administrator consent for the delegated permissions below. To connect through OAuth, self-hosted deployments require `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, and the matching `/api/auth/oauth2/callback/microsoft-intune` callback. To run as an app registration instead of a signed-in administrator, use a [Microsoft app credential](/integrations/microsoft-app-credentials) with the matching application permissions; every operation supports them. diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index fb03f56714a..39d2c1ce012 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -549,7 +549,11 @@ Return ONLY the JSON array.`, apiKey: { type: 'string', description: 'Provider API key' }, azureEndpoint: { type: 'string', description: 'Azure endpoint URL' }, azureApiVersion: { type: 'string', description: 'Azure API version' }, - vertexCredential: { type: 'string', description: 'OAuth credential for Vertex AI' }, + vertexCredential: { + type: 'string', + description: + 'Google Cloud credential for Vertex AI (OAuth account or Google service account)', + }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, diff --git a/apps/sim/blocks/blocks/intune.ts b/apps/sim/blocks/blocks/intune.ts index 5a6de413bad..c8bdca0cd48 100644 --- a/apps/sim/blocks/blocks/intune.ts +++ b/apps/sim/blocks/blocks/intune.ts @@ -8,7 +8,7 @@ export const IntuneBlock: BlockConfig = { name: 'Microsoft Intune', description: 'Manage devices, application inventory, and compliance with Microsoft Intune', longDescription: - 'Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account and an active Intune tenant license. Remote actions depend on device platform and the connected administrator’s Intune permissions.', + 'Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account, or a Microsoft app credential with application permissions, and an active Intune tenant license. Remote actions depend on device platform and on the connected administrator’s Intune permissions or the app’s application permissions.', docsLink: 'https://docs.sim.ai/integrations/intune', category: 'tools', integrationType: IntegrationType.Security, diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 9cc795c3ea5..7535dca9a5a 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -476,7 +476,7 @@ export function getCohereRerankerApiKeyCondition() { /** * Whether `model` can only run with credentials that live on the block beyond an - * API key: a Vertex OAuth credential, Bedrock AWS keys, or an Azure endpoint, + * API key: a Vertex credential, Bedrock AWS keys, or an Azure endpoint, * unless the deployment supplies them server-side (the same env flags that hide * those fields). The fields render only while the block's own `model` is in * that provider family, so nothing outside the family can inherit them. @@ -631,7 +631,8 @@ export const PROVIDER_CREDENTIAL_INPUTS = { vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, vertexCredential: { type: 'string', - description: 'Google Cloud OAuth credential ID for Vertex AI', + description: + 'Google Cloud credential ID for Vertex AI (OAuth account or Google service account)', }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' }, diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 16bc644b243..9a48a2b9339 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -125,6 +125,8 @@ function fakeMicrosoft( tokenError?: Response /** Refusals answered to the first token requests, in order, before Entra starts issuing tokens. */ tokenErrorsFirst?: Response[] + /** How long Entra takes to answer each token request. */ + tokenDelayMs?: number } = {} ) { const tokenRequests: TokenRequest[] = [] @@ -133,6 +135,9 @@ function fakeMicrosoft( if (url.startsWith('https://login.microsoftonline.com/')) { const form = Object.fromEntries(new URLSearchParams(String(init.body))) tokenRequests.push({ url, form, redirect: init.redirect }) + if (options.tokenDelayMs) { + await new Promise((resolve) => setTimeout(resolve, options.tokenDelayMs)) + } if (options.tokenError) return options.tokenError.clone() const pendingError = pendingErrors.shift() if (pendingError) return pendingError @@ -526,7 +531,7 @@ describe('getting a Microsoft app token for a run', () => { ) beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout'] }) + vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] }) }) afterEach(() => { @@ -544,21 +549,38 @@ describe('getting a Microsoft app token for a run', () => { return outcome.value } - it.each([70021, 700213])( - 'retries an AADSTS%i refusal and returns the token Entra then issues', - async (code) => { - const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(code), refusal(code)] }) - storeCredential( - { authType: 'federated', subject: SUBJECT }, - `ciphertext-propagating-${code}` - ) + it('retries an AADSTS70021 refusal and returns the token Entra then issues', async () => { + const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(70021), refusal(70021)] }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-propagating') - await expect( - settle(resolveMicrosoftAppCredentialToken(`cred-propagating-${code}`, 'microsoft-ad')) - ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) - expect(tokens).toHaveLength(3) - } - ) + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-propagating', 'microsoft-ad')) + ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) + expect(tokens).toHaveLength(3) + }) + + it('does not retry AADSTS700213, the documented case-sensitive mismatch', async () => { + const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(700213)] }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-mismatched') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-mismatched', 'microsoft-ad')) + ).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: { reason: 'AADSTS700213' }, + }) + expect(tokens).toHaveLength(1) + }) + + it('stops retrying when a slow Entra would push the run past the shared mint deadline', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021), tokenDelayMs: 11_000 }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-slow-refusal') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-slow-refusal', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) it('asks for a reconnect once the retries are spent', async () => { const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021) }) diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index ab939420788..f1ded209f1a 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -66,8 +66,17 @@ const CERTIFICATE_CLOCK_SKEW_MS = 5 * 60 * 1000 /** Extra token requests a run makes while a new federated credential propagates through Entra. */ const FEDERATION_PROPAGATION_RETRIES = 2 -/** The refusals Entra answers while a new federated credential has not reached every node. */ -const FEDERATION_PROPAGATION_AADSTS = new Set([70021, 700213]) +/** + * The refusal Microsoft documents for a federated credential that has not reached every Entra + * node yet. AADSTS700213 is left out: Microsoft documents it only as a case-sensitive mismatch. + */ +const FEDERATION_PROPAGATION_AADSTS = 70021 +/** + * A run's whole token request, retries included, settles inside this budget so it stays under the + * 30 s deadline that concurrent runs sharing the mint wait for. A retry is only started when even + * a request that runs to its full timeout would still finish in time. + */ +const RUN_TOKEN_BUDGET_MS = 25_000 /** Re-mint once less than this much of a token's lifetime remains. */ const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000 @@ -78,12 +87,18 @@ const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ const TENANT_DOMAIN_PATTERN = /^(?=.{4,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/ -/** The Microsoft services that may use this credential, and the resource each one calls. */ -type MicrosoftAppService = - | 'microsoft-ad' - | 'sharepoint' - | 'microsoft-intune' - | 'microsoft-dataverse' +/** + * The Microsoft services that may use this credential. Each one's `serviceAccountProviderId` in + * `oauth.ts` offers the credential in its picker; this list is what the resolver accepts. + */ +const MICROSOFT_APP_SERVICES = [ + 'microsoft-ad', + 'sharepoint', + 'microsoft-intune', + 'microsoft-dataverse', +] as const + +type MicrosoftAppService = (typeof MICROSOFT_APP_SERVICES)[number] const tenantSchema = z .string() @@ -619,8 +634,9 @@ export async function verifyAndEncryptMicrosoftAppCredential( /** * Requests a run's token, retrying a federated credential's "no matching federated identity * record" refusal: Entra can answer it for several minutes after the federated credential is - * added, even after a token was issued, and Microsoft asks callers to retry. The save-time - * subject-pin probe never comes here, because it expects that very refusal. + * added, even after a token was issued, and Microsoft asks callers to retry. The retries stop + * within {@link RUN_TOKEN_BUDGET_MS}. The save-time subject-pin probe never comes here, because + * it expects that very refusal. * @see https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-considerations#time-for-federated-credential-changes-to-propagate */ async function requestRunEntraToken( @@ -628,6 +644,7 @@ async function requestRunEntraToken( resource: string, owner: WorkloadIdentityOwner ): Promise { + const deadline = Date.now() + RUN_TOKEN_BUDGET_MS for (let attempt = 1; ; attempt++) { try { return await requestEntraToken(config, resource, owner) @@ -635,10 +652,11 @@ async function requestRunEntraToken( const propagating = config.authType === 'federated' && error instanceof MicrosoftAppCredentialError && - error.aadsts !== undefined && - FEDERATION_PROPAGATION_AADSTS.has(error.aadsts) + error.aadsts === FEDERATION_PROPAGATION_AADSTS if (!propagating || attempt > FEDERATION_PROPAGATION_RETRIES) throw error - await sleep(backoffWithJitter(attempt, null)) + const delay = backoffWithJitter(attempt, null) + if (Date.now() + delay + REQUEST_TIMEOUT_MS > deadline) throw error + await sleep(delay) } } } @@ -661,12 +679,7 @@ export interface MicrosoftAppCredentialToken { } function isMicrosoftAppService(value: string | undefined): value is MicrosoftAppService { - return ( - value === 'microsoft-ad' || - value === 'sharepoint' || - value === 'microsoft-intune' || - value === 'microsoft-dataverse' - ) + return (MICROSOFT_APP_SERVICES as readonly (string | undefined)[]).includes(value) } /** diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index cfd560475e3..1621e76cfc8 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -105,7 +105,8 @@ export const llmChatTool: InternalToolConfig = { type: 'string', required: false, visibility: 'hidden', - description: 'Google Cloud OAuth credential ID for Vertex AI', + description: + 'Google Cloud credential ID for Vertex AI (OAuth account or Google service account)', }, bedrockAccessKeyId: { type: 'string', From 7b2314357c2c98cc1b706c06507193077f34fe0b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:48:34 -0800 Subject: [PATCH 32/83] feat(credentials): Atlassian service-account OAuth 2.0 credentials An Atlassian service account can now connect with its OAuth 2.0 credential (client ID and secret) as well as a scoped API token. Saved with authMethod client_credentials, the credential stores the client credentials, the site, its cloudId from tenant_info, and the product; execution mints a 60-minute token at auth.atlassian.com with the client credentials grant and calls api.atlassian.com/ex/{product}/{cloudId} exactly as a scoped token does. Connecting proves the minted token against the product's current-user endpoint. Atlassian documents these credentials for Jira and Confluence only, so other products are refused. It does not say whether a new token retires earlier ones, so every saved credential for one OAuth client shares one coordinated token. Scoped-token credentials, their blob, and their resolution are unchanged; the identity request now refuses redirects. --- .../atlassian-service-account.mdx | 13 +- .../connect-service-account-modal.tsx | 159 ++++++++++++++---- .../token-service-account-modal.tsx | 12 +- .../atlassian-client-credentials.test.ts | 92 ++++++++++ .../atlassian-client-credentials.ts | 123 ++++++++++++++ .../credentials/atlassian-service-account.ts | 1 + .../service-account-fields.test.ts | 12 ++ .../lib/credentials/service-account-fields.ts | 5 +- .../service-account-secret.test.ts | 51 ++++++ .../lib/credentials/service-account-secret.ts | 75 ++++++++- .../token-service-accounts/descriptors.ts | 6 +- .../token-service-accounts/server.ts | 10 +- apps/sim/lib/oauth/credential-service.test.ts | 87 ++++++++++ apps/sim/lib/oauth/credential-service.ts | 101 +++++++++-- 14 files changed, 678 insertions(+), 69 deletions(-) create mode 100644 apps/sim/lib/credentials/atlassian-client-credentials.test.ts create mode 100644 apps/sim/lib/credentials/atlassian-client-credentials.ts diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx index 76f7eea35de..ba1769ef676 100644 --- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx @@ -97,12 +97,21 @@ Use the **App** and **Scope type** filters to find both classic and granular sco height={458} /> +## Use an OAuth 2.0 credential instead of a token [#oauth-2-0-credential] + +A service account can also have an [OAuth 2.0 credential](https://support.atlassian.com/user-management/docs/create-oauth-2-0-credential-for-service-accounts/): a client ID and secret with the scopes you select when you create it. Sim requests a 60-minute access token from `auth.atlassian.com` with the client credentials grant whenever a workflow needs one and calls `api.atlassian.com/ex/{jira|confluence}/{cloudId}` with it, so nothing expires in Sim the way a pasted token does. + +- Atlassian supports these credentials for the **Jira** and **Confluence** REST APIs. It does not list Jira Service Management, so use a scoped API token for Service Management operations. +- In **Directory → Service accounts**, select the service account, then **Create credentials → OAuth 2.0**, choose the scopes listed above, and copy the client ID and secret. Atlassian shows the secret only once. +- When you add the credential in Sim, set **Authentication** to **OAuth 2.0 credential (client credentials)** and paste the client ID and secret instead of an API token. +- Once the OAuth 2.0 credential is revoked in Atlassian, Sim can no longer request tokens with it. To rotate, create a new credential and **Reconnect** the Sim credential with its client ID and secret. + ## Add the credential to Sim 1. Open **Integrations** in your workspace sidebar, then **Jira**, **Jira Service Management**, or **Confluence**. 2. Select **Add to Sim → Add service account**. If only service accounts are available, select **Add service account** directly. -3. Paste the **API token** and enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. -4. Select **Add service account**. Sim checks the token against the selected product's current-user endpoint; resolve any error before continuing. +3. Keep **Authentication** on **Scoped API token** and paste the **API token**, or choose [**OAuth 2.0 credential**](#oauth-2-0-credential) and paste its client ID and secret. Enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. +4. Select **Add service account**. Sim checks the token (or a token minted from the OAuth 2.0 credential) against the selected product's current-user endpoint; resolve any error before continuing. 5. In your workflow's Jira, Jira Service Management, or Confluence block, select the credential and configure the operation. The credential appears on all three integration pages, but adding it only validates the selected product. Check account access and scopes before using another product. diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 15249331c63..4c91d88e704 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -32,6 +32,7 @@ import { } from '@/lib/credentials/google-workload-identity' import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' import { + CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, type TokenServiceAccountProviderId, @@ -127,8 +128,18 @@ const ATLASSIAN_ERROR_MESSAGES: Record = { duplicate_display_name: 'A credential with that name already exists in this workspace.', atlassian_unavailable: "We couldn't reach Atlassian to verify these credentials. Try again in a moment.", + provider_unavailable: + "We couldn't reach Atlassian to verify these credentials. Try again in a moment.", +} + +/** Copy that differs when the service account signs in with an OAuth 2.0 credential. */ +const ATLASSIAN_CLIENT_CREDENTIAL_ERROR_MESSAGES: Record = { + invalid_credentials: + "We couldn't authenticate with that client ID and secret. Check that they belong to an OAuth 2.0 credential of a service account with access to this site and product.", } +type AtlassianAuthMethod = 'api_token' | typeof CLIENT_CREDENTIALS_AUTH_METHOD + const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment." function normalizeAtlassianDomain(raw: string): string { @@ -138,11 +149,13 @@ function normalizeAtlassianDomain(raw: string): string { .replace(/\/+$/, '') } -function messageForAtlassianError(err: unknown): string { - if (isApiClientError(err) && err.code && ATLASSIAN_ERROR_MESSAGES[err.code]) { - return ATLASSIAN_ERROR_MESSAGES[err.code] - } - return FALLBACK_ERROR_MESSAGE +function messageForAtlassianError(err: unknown, authMethod: AtlassianAuthMethod): string { + if (!isApiClientError(err) || !err.code) return FALLBACK_ERROR_MESSAGE + const clientMessage = + authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + ? ATLASSIAN_CLIENT_CREDENTIAL_ERROR_MESSAGES[err.code] + : undefined + return clientMessage ?? ATLASSIAN_ERROR_MESSAGES[err.code] ?? FALLBACK_ERROR_MESSAGE } interface ConnectServiceAccountModalProps { @@ -960,7 +973,10 @@ function AtlassianServiceAccountModalForm({ initialDescription, onCreated, }: AtlassianServiceAccountModalProps) { + const [authMethod, setAuthMethod] = useState('api_token') const [apiToken, setApiToken] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') const [domain, setDomain] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') @@ -969,26 +985,40 @@ function AtlassianServiceAccountModalForm({ const createCredential = useCreateScopedCredential() const updateCredential = useUpdateScopedCredential() + const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD const trimmedToken = apiToken.trim() const normalizedDomain = normalizeAtlassianDomain(domain) const showDomainHint = normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain) const isPending = createCredential.isPending || updateCredential.isPending - const isDisabled = !trimmedToken || !normalizedDomain || isPending + const isDisabled = + (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || + !normalizedDomain || + isPending + + const clearError = () => { + if (error) setError(null) + } const handleSubmit = async () => { setError(null) if (isDisabled) return + const secretFields = usesClientCredentials + ? { + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + domain: normalizedDomain, + } + : { apiToken: trimmedToken, domain: normalizedDomain } try { let connectedCredentialId = credentialId if (credentialId) { await updateCredential.mutateAsync({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), credentialId, - apiToken: trimmedToken, - domain: normalizedDomain, - + ...secretFields, displayName: displayName.trim() || undefined, description: description.trim() || undefined, }) @@ -997,8 +1027,7 @@ function AtlassianServiceAccountModalForm({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), type: 'service_account', providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - apiToken: trimmedToken, - domain: normalizedDomain, + ...secretFields, atlassianProduct, displayName: displayName.trim() || undefined, description: description.trim() || undefined, @@ -1008,7 +1037,7 @@ function AtlassianServiceAccountModalForm({ if (connectedCredentialId) onCreated?.(connectedCredentialId) onOpenChange(false) } catch (err: unknown) { - setError(messageForAtlassianError(err)) + setError(messageForAtlassianError(err, authMethod)) logger.error('Failed to add Atlassian service account credential', err) } } @@ -1024,33 +1053,97 @@ function AtlassianServiceAccountModalForm({ { + setAuthMethod(value as AtlassianAuthMethod) + clearError() + }} + options={[ + { value: 'api_token', label: 'Scoped API token' }, + { + value: CLIENT_CREDENTIALS_AUTH_METHOD, + label: 'OAuth 2.0 credential (client credentials)', + }, + ]} required hint={ - atlassianProduct === 'confluence' - ? 'Use a service-account token with Confluence access. Required scopes are listed in the setup guide.' - : ATLASSIAN_COVERAGE_HINT + usesClientCredentials + ? 'Sim requests a 60-minute token with the client ID and secret. Atlassian supports these credentials for Jira and Confluence.' + : undefined } - > - {(aria) => ( - + + {usesClientCredentials ? ( + <> + { - setApiToken(value) - if (error) setError(null) + setClientId(value) + clearError() }} - placeholder='Paste API token' - name='atlassian_service_account_api_token' - autoComplete='new-password' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' + placeholder='Paste the client ID' + autoComplete='off' + required + hint={ + atlassianProduct === 'confluence' + ? 'Use an OAuth 2.0 credential with Confluence scopes. Required scopes are listed in the setup guide.' + : ATLASSIAN_COVERAGE_HINT + } /> - )} - + + {(aria) => ( + { + setClientSecret(value) + clearError() + }} + placeholder='Paste the client secret' + name='atlassian_service_account_client_secret' + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + + ) : ( + + {(aria) => ( + { + setApiToken(value) + clearError() + }} + placeholder='Paste API token' + name='atlassian_service_account_api_token' + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + )} (null) const authMethod: TokenAuthMethod = selectedAuthMethod ?? - (clientCredentials?.preferred && !credentialId ? TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD : 'token') - const usesClientCredentials = authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + (clientCredentials?.preferred && !credentialId ? CLIENT_CREDENTIALS_AUTH_METHOD : 'token') + const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD const [apiToken, setApiToken] = useState('') const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') @@ -142,7 +142,7 @@ function TokenServiceAccountModalForm({ const secretFields = { ...(usesClientCredentials ? { - authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, clientId: clientId.trim(), clientSecret: clientSecret.trim(), } @@ -215,7 +215,7 @@ function TokenServiceAccountModalForm({ if (error) setError(null) }} options={[ - { value: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, label: clientCredentials.label }, + { value: CLIENT_CREDENTIALS_AUTH_METHOD, label: clientCredentials.label }, { value: 'token', label: clientCredentials.tokenLabel }, ]} required diff --git a/apps/sim/lib/credentials/atlassian-client-credentials.test.ts b/apps/sim/lib/credentials/atlassian-client-credentials.test.ts new file mode 100644 index 00000000000..dcb0fdc9032 --- /dev/null +++ b/apps/sim/lib/credentials/atlassian-client-credentials.test.ts @@ -0,0 +1,92 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' + +const mockFetch = vi.fn() + +describe('Atlassian service-account OAuth 2.0 credentials', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.round((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('exchanges the client credentials at the fixed Atlassian token endpoint', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://auth.atlassian.com/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'atl-client'], + ['client_secret', 'atl-secret'], + ]) + return jsonResponse({ access_token: 'atl-token', expires_in: 3600, token_type: 'Bearer' }) + }) + + const minted = await mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + + expect(minted.accessToken).toBe('atl-token') + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'atlassian', application: ['atl-client'] }) + ) + }) + + it('never keeps a token longer than the documented 60 minutes', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'atl-token', expires_in: 86400 })) + + const minted = await mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + + expect(minted.expiresInSeconds).toBeLessThanOrEqual(3600) + }) + + it.each([ + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [500, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'access_denied' }, { status })) + + await expect( + mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + ).rejects.toMatchObject({ code }) + }) + + it.each([ + ['no access token', { expires_in: 3600 }], + ['no lifetime', { access_token: 'atl-token' }], + ])('refuses a token response with %s', async (_label, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect( + mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('never sends a request without a client secret', async () => { + await expect(mintAtlassianServiceAccountToken('atl-client', '')).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(coordinated).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/atlassian-client-credentials.ts b/apps/sim/lib/credentials/atlassian-client-credentials.ts new file mode 100644 index 00000000000..6ac99509f87 --- /dev/null +++ b/apps/sim/lib/credentials/atlassian-client-credentials.ts @@ -0,0 +1,123 @@ +import { isRecordLike } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { AtlassianProduct } from '@/lib/credentials/service-account-fields' +import { + fetchProvider, + providerFailureCode, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token' +const MINT_STEP = 'atlassian_token_mint' +/** Atlassian documents a 60-minute access token for service-account OAuth credentials. */ +const MAX_TOKEN_LIFETIME_SECONDS = 3600 + +/** + * Products whose REST APIs accept a service account's OAuth 2.0 client-credentials token. + * Atlassian documents Jira Cloud and Confluence Cloud (and Jira Align, which Sim does not call); + * a product outside this list is refused instead of saving a credential its tools cannot use. + */ +export const ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS: ReadonlySet = new Set([ + 'jira', + 'confluence', +]) + +async function exchangeAtlassianToken( + clientId: string, + clientSecret: string, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresAt: Date }> { + const startedAt = Date.now() + const response = await fetchProvider( + ATLASSIAN_TOKEN_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }).toString(), + redirect: 'error', + cache: 'no-store', + signal, + }, + MINT_STEP + ) + if (!response.ok) { + const body = await readProviderErrorSnippet(response) + throw new TokenServiceAccountValidationError( + providerFailureCode(response.status), + response.status, + { + step: MINT_STEP, + body, + } + ) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: 64 * 1024, + label: 'Atlassian token response', + signal, + }) + } catch { + signal?.throwIfAborted() + payload = undefined + } + if ( + !isRecordLike(payload) || + typeof payload.access_token !== 'string' || + !payload.access_token.trim() || + typeof payload.expires_in !== 'number' || + !Number.isSafeInteger(payload.expires_in) || + payload.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: MINT_STEP, + reason: 'provider returned an invalid token response', + }) + } + return { + accessToken: payload.access_token, + expiresAt: new Date( + startedAt + Math.min(payload.expires_in, MAX_TOKEN_LIFETIME_SECONDS) * 1000 + ), + } +} + +/** + * Mints a bearer token for an Atlassian service account's OAuth 2.0 credential through the + * client-credentials grant. The token is used exactly like a scoped API token, through + * `api.atlassian.com/ex/{product}/{cloudId}`. Atlassian does not document whether a new token + * retires earlier ones, so every saved credential for one OAuth client shares a single + * coordinated token instead of minting per worker. + * + * @see https://support.atlassian.com/user-management/docs/create-oauth-2-0-credential-for-service-accounts/ + */ +export async function mintAtlassianServiceAccountToken( + clientId: string, + clientSecret: string, + options?: { signal?: AbortSignal } +): Promise<{ accessToken: string; expiresInSeconds: number }> { + if (!clientId || !clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: MINT_STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'atlassian', + application: [clientId], + clientSecret, + step: MINT_STEP, + exchange: (signal) => exchangeAtlassianToken(clientId, clientSecret, signal), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/atlassian-service-account.ts b/apps/sim/lib/credentials/atlassian-service-account.ts index a3ac12ede1e..e5a737cf531 100644 --- a/apps/sim/lib/credentials/atlassian-service-account.ts +++ b/apps/sim/lib/credentials/atlassian-service-account.ts @@ -116,6 +116,7 @@ export async function validateAtlassianServiceAccount( `https://api.atlassian.com/ex/${product}/${cloudId}/${identityPath}`, { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, + redirect: 'error', } ) await assertAtlassianResponseOk(myselfRes, 'myself', 'invalid_credentials', { cloudId }) diff --git a/apps/sim/lib/credentials/service-account-fields.test.ts b/apps/sim/lib/credentials/service-account-fields.test.ts index 9689deb1479..d3a8322b769 100644 --- a/apps/sim/lib/credentials/service-account-fields.test.ts +++ b/apps/sim/lib/credentials/service-account-fields.test.ts @@ -54,4 +54,16 @@ describe('required fields for a service-account create', () => { }) ).toEqual(['apiToken']) }) + + it('asks an Atlassian OAuth 2.0 create for the client ID, secret, and site, not a token', () => { + expect( + getMissingServiceAccountFields('atlassian-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + expect(getMissingServiceAccountFields('atlassian-service-account', {})).toEqual([ + 'apiToken', + 'domain', + ]) + }) }) diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 53358c056ae..6413561d0da 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,6 +1,6 @@ import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' import { - TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, } from '@/lib/credentials/token-service-accounts/descriptors' @@ -112,6 +112,7 @@ function getServiceAccountRequiredFields( * offer a client-credentials grant, selected by `authMethod: 'client_credentials'`. */ const CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS: Record = { + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['clientId', 'clientSecret', 'domain'], ...TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, } @@ -119,7 +120,7 @@ function getClientCredentialsModeFields( providerId: string | null | undefined, authMethod: unknown ): readonly ServiceAccountFieldId[] | undefined { - return authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD && + return authMethod === CLIENT_CREDENTIALS_AUTH_METHOD && providerId && Object.hasOwn(CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS, providerId) ? CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS[providerId] diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index d9f6d671f20..e78486fd173 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -117,6 +117,57 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('stores an Atlassian OAuth 2.0 credential, not its minted token, after proving it', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + mockValidateAtlassian.mockResolvedValue({ + accountId: 'svc-1', + displayName: 'Workflow bot', + cloudId: 'cloud-1', + }) + + const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'Acme.atlassian.net', + atlassianProduct: 'confluence', + }) + + expect(mockValidateAtlassian).toHaveBeenCalledWith( + 'atl-minted', + 'acme.atlassian.net', + 'confluence' + ) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + atlassianProduct: 'confluence', + atlassianAccountId: 'svc-1', + }) + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('atl-minted') + }) + + it('requires the Atlassian client ID, secret, and site without minting', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + domain: 'acme.atlassian.net', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('accepts a legacy Google create with an empty providerId', async () => { const json = JSON.stringify({ type: 'service_account', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index c6620632e67..1b923b02f2e 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -2,6 +2,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { encryptSecret } from '@/lib/core/security/encryption' +import { + ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS, + mintAtlassianServiceAccountToken, +} from '@/lib/credentials/atlassian-client-credentials' import { normalizeAtlassianDomain, validateAtlassianServiceAccount, @@ -50,10 +54,10 @@ import { } from '@/lib/credentials/snowflake-service-account.server' import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' import { + CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, - TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, type TokenServiceAccountDescriptor, } from '@/lib/credentials/token-service-accounts/descriptors' @@ -148,6 +152,9 @@ export class ServiceAccountSecretError extends Error { async function buildAtlassianServiceAccountSecret( fields: ServiceAccountSecretFields ): Promise { + if (fields.authMethod?.trim() === CLIENT_CREDENTIALS_AUTH_METHOD) { + return buildAtlassianClientCredentialSecret(fields) + } const { apiToken, domain } = fields if (!apiToken || !domain) { throw new ServiceAccountSecretError( @@ -187,6 +194,66 @@ async function buildAtlassianServiceAccountSecret( } } +/** + * Builds an Atlassian service-account secret for an OAuth 2.0 credential: mints a token with the + * client ID and secret, proves it against the product's identity endpoint exactly as a scoped + * API token is proven, and stores the client credentials so execution re-mints. Only products + * Atlassian documents for these credentials are accepted. + */ +async function buildAtlassianClientCredentialSecret( + fields: ServiceAccountSecretFields +): Promise { + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const domain = fields.domain?.trim() + if (!clientId || !clientSecret || !domain) { + throw new ServiceAccountSecretError( + 'clientId, clientSecret, and domain are required for Atlassian OAuth 2.0 credentials' + ) + } + const product = fields.atlassianProduct ?? 'jira' + if (!ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS.has(product)) { + throw new ServiceAccountSecretError( + `Atlassian OAuth 2.0 credentials do not support ${product}; use a scoped API token` + ) + } + const normalizedDomain = normalizeAtlassianDomain(domain) + const minted = await mintAtlassianServiceAccountToken(clientId, clientSecret) + const validation = await validateAtlassianServiceAccount( + minted.accessToken, + normalizedDomain, + product + ) + const principal: ServiceAccountPrincipal = { + kind: 'user', + id: validation.accountId, + ...(validation.emailAddress ? { label: validation.emailAddress } : {}), + } + const blob = JSON.stringify({ + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId, + clientSecret, + domain: normalizedDomain, + cloudId: validation.cloudId, + atlassianProduct: product, + atlassianAccountId: validation.accountId, + metadata: serviceAccountPrincipalMetadata(principal), + }) + const { encrypted } = await encryptSecret(blob) + return { + providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { + atlassianDomain: normalizedDomain, + atlassianCloudId: validation.cloudId, + ...serviceAccountPrincipalMetadata(principal), + }, + principal, + } +} + /** * Builds a custom Slack bot secret. The workspace/team identity is derived via * `auth.test` and never trusted from the client. @@ -478,7 +545,7 @@ async function buildTokenServiceAccountSecret( } if ( descriptor.clientCredentials && - fields.authMethod?.trim() === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + fields.authMethod?.trim() === CLIENT_CREDENTIALS_AUTH_METHOD ) { return buildTokenClientCredentialSecret(descriptor, validator, fields) } @@ -549,13 +616,13 @@ async function buildTokenClientCredentialSecret( const validation = await validator({ apiToken: minted.accessToken, ...(requiresDomain ? { domain } : {}), - authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, }) const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountClientCredentialBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId, - authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, clientId, clientSecret, ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index c220cdb467f..7b0205be638 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -95,8 +95,8 @@ export interface TokenServiceAccountClientCredentials { preferred?: boolean } -/** `authMethod` value selecting a token provider's client-credentials mode. */ -export const TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const +/** `authMethod` value selecting the client-credentials mode of a provider whose default is a pasted token (a token provider, or the Atlassian service account). */ +export const CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const @@ -2443,7 +2443,7 @@ export function getTokenServiceAccountErrorMessage( authMethod?: string ): string { const clientCredentials = - authMethod === TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD ? descriptor.clientCredentials : undefined + authMethod === CLIENT_CREDENTIALS_AUTH_METHOD ? descriptor.clientCredentials : undefined switch (code) { case 'invalid_credentials': return ( diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index e617a2b2fb2..d3b47e04bc4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -25,6 +25,7 @@ import { CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID, CLERK_SERVICE_ACCOUNT_PROVIDER_ID, CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, + CLIENT_CREDENTIALS_AUTH_METHOD, CLOUDFLARE_SERVICE_ACCOUNT_PROVIDER_ID, CODA_SERVICE_ACCOUNT_PROVIDER_ID, CONVEX_SERVICE_ACCOUNT_PROVIDER_ID, @@ -108,7 +109,6 @@ import { TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID, THRIVE_SERVICE_ACCOUNT_PROVIDER_ID, TINYBIRD_SERVICE_ACCOUNT_PROVIDER_ID, - TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, type TokenServiceAccountProviderId, TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, @@ -257,7 +257,7 @@ export interface TokenServiceAccountFields { * grant rather than pasted, for checks whose identity differs between the two (a Linear app * actor). Absent for a pasted token. */ - authMethod?: typeof TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + authMethod?: typeof CLIENT_CREDENTIALS_AUTH_METHOD } /** Result of a successful provider verification. */ @@ -450,7 +450,7 @@ export interface TokenServiceAccountSecretBlob { export interface TokenServiceAccountClientCredentialBlob { type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE providerId: string - authMethod: typeof TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD + authMethod: typeof CLIENT_CREDENTIALS_AUTH_METHOD clientId: string clientSecret: string domain?: string @@ -505,7 +505,7 @@ export function parseTokenServiceAccountSecret( } } if ( - parsed.authMethod !== TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD || + parsed.authMethod !== CLIENT_CREDENTIALS_AUTH_METHOD || typeof parsed.clientId !== 'string' || !parsed.clientId || typeof parsed.clientSecret !== 'string' || @@ -516,7 +516,7 @@ export function parseTokenServiceAccountSecret( return { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId: expectedProviderId, - authMethod: TOKEN_CLIENT_CREDENTIALS_AUTH_METHOD, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, clientId: parsed.clientId, clientSecret: parsed.clientSecret, ...optional, diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index dc2a0b41bde..fa68b82c920 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -1444,3 +1444,90 @@ describe('token provider credentials saved in client-credentials mode', () => { expect(fetchMock).not.toHaveBeenCalled() }) }) + +describe('Atlassian service-account resolution', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + }) + + function storeBlob(blob: Record, reads: number) { + for (let read = 0; read < reads; read++) { + queueTableRows(credential, [{ encryptedServiceAccountKey: 'enc-atlassian' }]) + } + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + } + + it('mints a token for an OAuth 2.0 credential and keeps its site binding', async () => { + storeBlob( + { + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + atlassianProduct: 'jira', + }, + 2 + ) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + + await expect( + resolveServiceAccountToken('atl-cc', 'atlassian-service-account') + ).resolves.toEqual({ + accessToken: 'atl-minted', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + expect(new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]).url).toBe( + 'https://auth.atlassian.com/oauth/token' + ) + }) + + it('still returns a scoped API token as is', async () => { + storeBlob( + { + type: 'atlassian_service_account', + apiToken: 'scoped-token', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('atl-token', 'atlassian-service-account') + ).resolves.toEqual({ + accessToken: 'scoped-token', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refuses a blob with neither a token nor a complete OAuth 2.0 credential', async () => { + storeBlob( + { + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('atl-broken', 'atlassian-service-account') + ).rejects.toThrow('malformed') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index f74daf7bea7..83c53139a53 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -4,12 +4,14 @@ import { account, credential } from '@sim/db/schema' import { sealedTextEquals } from '@sim/db/sealed-text' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, isNotNull, sql } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' import { waitWithAbort } from '@/lib/core/utils/concurrency' +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' import { isClientCredentialAccountProviderId, VANTA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -21,6 +23,7 @@ import { import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' import { + CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -499,16 +502,72 @@ export async function getSlackBotCredential( } } -interface AtlassianServiceAccountSecret { +interface AtlassianServiceAccountSecretBase { type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE - apiToken: string domain: string cloudId: string atlassianAccountId?: string } +/** A scoped API token, used as the bearer token as is. */ +interface AtlassianServiceAccountTokenSecret extends AtlassianServiceAccountSecretBase { + apiToken: string +} + +/** An OAuth 2.0 credential, minted into a bearer token with the client-credentials grant. */ +interface AtlassianServiceAccountClientSecret extends AtlassianServiceAccountSecretBase { + authMethod: typeof CLIENT_CREDENTIALS_AUTH_METHOD + clientId: string + clientSecret: string +} + +type AtlassianServiceAccountSecret = + | AtlassianServiceAccountTokenSecret + | AtlassianServiceAccountClientSecret + +function parseAtlassianServiceAccountSecret(decrypted: string): AtlassianServiceAccountSecret { + const malformed = new Error('Stored Atlassian service account secret is malformed') + const parsed: unknown = JSON.parse(decrypted) + if ( + !isRecordLike(parsed) || + parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE || + typeof parsed.cloudId !== 'string' || + !parsed.cloudId + ) { + throw malformed + } + const base: AtlassianServiceAccountSecretBase = { + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + domain: typeof parsed.domain === 'string' ? parsed.domain : '', + cloudId: parsed.cloudId, + ...(typeof parsed.atlassianAccountId === 'string' + ? { atlassianAccountId: parsed.atlassianAccountId } + : {}), + } + if (parsed.authMethod === undefined) { + if (typeof parsed.apiToken !== 'string' || !parsed.apiToken) throw malformed + return { ...base, apiToken: parsed.apiToken } + } + if ( + parsed.authMethod !== CLIENT_CREDENTIALS_AUTH_METHOD || + typeof parsed.clientId !== 'string' || + !parsed.clientId || + typeof parsed.clientSecret !== 'string' || + !parsed.clientSecret + ) { + throw malformed + } + return { + ...base, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: parsed.clientId, + clientSecret: parsed.clientSecret, + } +} + /** - * Loads the decrypted Atlassian service account secret blob for a credential. + * Loads the decrypted Atlassian service account secret blob for a credential: + * a scoped API token, or an OAuth 2.0 credential's client ID and secret. * Throws if the credential is missing or not an Atlassian service account. */ export async function getAtlassianServiceAccountSecret( @@ -525,15 +584,7 @@ export async function getAtlassianServiceAccountSecret( } const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) - const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret - if ( - parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE || - !parsed.apiToken || - !parsed.cloudId - ) { - throw new Error('Stored Atlassian service account secret is malformed') - } - return parsed + return parseAtlassianServiceAccountSecret(decrypted) } /** @@ -907,9 +958,31 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, options) => { const secret = await getAtlassianServiceAccountSecret(credentialId) - return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } + if (!('authMethod' in secret)) { + return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } + } + return resolveMintedServiceAccountToken( + credentialId, + async (decrypted) => { + const stored = parseAtlassianServiceAccountSecret(decrypted) + if (!('authMethod' in stored)) { + throw new Error('Stored Atlassian service account secret changed mode') + } + const minted = await mintAtlassianServiceAccountToken(stored.clientId, stored.clientSecret) + return { + token: { + accessToken: minted.accessToken, + cloudId: stored.cloudId, + domain: stored.domain, + }, + expiresInSeconds: minted.expiresInSeconds, + } + }, + options, + true + ) }, [SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => { const botCredential = await getSlackBotCredential(credentialId) From 8cf19d6e1b138ecb1fff52ff2d570a94a248c2a4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:49:13 -0800 Subject: [PATCH 33/83] feat(credentials): saved Datadog API and application key credential and credential-only block A Datadog credential holds the site (from the DATADOG_SITES allowlist), the API key, and the application key. Datadog authenticates every request with the two static headers, so the "mint" resolves the API key as the token, the application key beside it, and the site as the credential's domain. - Save verifies the API key with GET https://api.{site}/api/v1/validate, sending DD-API-KEY alone, redirects refused: 403 is invalid_credentials, and a 200 must report valid: true. The application key is not probed: Datadog publishes no reference for an application-key check, and any other read would reject a correctly scoped key with a permission 403. Keys with control characters or whitespace are refused before any request. - Credential resolution carries a new applicationKey field (credential cache, token payload contract, executor credentialParams source), and the Assistant registers it as a resolved secret beside the token. - Every Datadog tool takes the saved credential: the credential fills apiKey, applicationKey (where the tool sends it), and site, overriding caller values. - datadog_v2 is the credential-only successor; the legacy block keeps its inline keys and site and is hidden from the toolbar. withSavedCredential accepts condition: null for a picker shown in every operation when the replaced fields show differently. --- .../content/docs/integrations/datadog.mdx | 2 + apps/sim/blocks/blocks/datadog.ts | 18 +++ apps/sim/blocks/registry-maps.ts | 3 +- apps/sim/blocks/utils.test.ts | 20 +++ apps/sim/blocks/utils.ts | 10 +- .../lib/api/contracts/oauth-connections.ts | 2 + .../client-credential-accounts/descriptors.ts | 46 +++++++ .../minters/datadog.test.ts | 98 +++++++++++++++ .../minters/datadog.ts | 117 ++++++++++++++++++ .../client-credential-accounts/server.ts | 14 ++- .../credential-service-cancellation.test.ts | 10 +- apps/sim/lib/oauth/credential-service.ts | 3 + apps/sim/lib/oauth/oauth.ts | 18 +++ apps/sim/lib/oauth/token-resolution.test.ts | 34 +++++ apps/sim/lib/oauth/token-resolution.ts | 1 + apps/sim/lib/oauth/types.ts | 2 + apps/sim/serializer/saved-credentials.test.ts | 52 +++++--- apps/sim/tools/datadog/add_incident_todo.ts | 2 + apps/sim/tools/datadog/cancel_downtime.ts | 2 + apps/sim/tools/datadog/create_dashboard.ts | 2 + apps/sim/tools/datadog/create_downtime.ts | 2 + apps/sim/tools/datadog/create_event.ts | 2 + apps/sim/tools/datadog/create_incident.ts | 2 + apps/sim/tools/datadog/create_monitor.ts | 2 + apps/sim/tools/datadog/create_slo.ts | 2 + apps/sim/tools/datadog/delete_dashboard.ts | 2 + apps/sim/tools/datadog/delete_slo.ts | 2 + .../datadog/get_browser_synthetics_results.ts | 2 + apps/sim/tools/datadog/get_dashboard.ts | 2 + apps/sim/tools/datadog/get_incident.ts | 2 + apps/sim/tools/datadog/get_monitor.ts | 2 + apps/sim/tools/datadog/get_security_signal.ts | 2 + apps/sim/tools/datadog/get_slo.ts | 2 + apps/sim/tools/datadog/get_slo_history.ts | 2 + .../tools/datadog/get_synthetics_results.ts | 2 + apps/sim/tools/datadog/get_synthetics_test.ts | 2 + apps/sim/tools/datadog/list_dashboards.ts | 2 + apps/sim/tools/datadog/list_downtimes.ts | 2 + apps/sim/tools/datadog/list_incidents.ts | 2 + apps/sim/tools/datadog/list_monitors.ts | 2 + apps/sim/tools/datadog/list_security_rules.ts | 2 + .../tools/datadog/list_security_signals.ts | 2 + apps/sim/tools/datadog/list_services.ts | 2 + apps/sim/tools/datadog/list_slos.ts | 2 + .../tools/datadog/list_synthetics_tests.ts | 2 + apps/sim/tools/datadog/mute_monitor.ts | 2 + apps/sim/tools/datadog/query_logs.ts | 2 + apps/sim/tools/datadog/query_timeseries.ts | 2 + apps/sim/tools/datadog/search_spans.ts | 2 + apps/sim/tools/datadog/send_logs.ts | 2 + apps/sim/tools/datadog/submit_metrics.ts | 2 + .../tools/datadog/trigger_synthetics_tests.ts | 2 + apps/sim/tools/datadog/unmute_monitor.ts | 2 + apps/sim/tools/datadog/update_incident.ts | 2 + .../update_security_signal_assignee.ts | 2 + .../datadog/update_security_signal_state.ts | 2 + apps/sim/tools/datadog/update_slo.ts | 2 + .../tools/datadog/update_synthetics_status.ts | 2 + apps/sim/tools/index.test.ts | 82 ++++++++++++ apps/sim/tools/index.ts | 2 + apps/sim/tools/shared/saved-credentials.ts | 18 +++ apps/sim/tools/types.ts | 7 +- 62 files changed, 613 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts diff --git a/apps/docs/content/docs/integrations/datadog.mdx b/apps/docs/content/docs/integrations/datadog.mdx index 93b13968ef8..1ae212e00d1 100644 --- a/apps/docs/content/docs/integrations/datadog.mdx +++ b/apps/docs/content/docs/integrations/datadog.mdx @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Datadog](https://datadoghq.com/) to send or query telemetry, manage monitors and incidents, and inspect observability data from a workflow. The actions below also cover dashboards, SLOs, synthetic tests, and security signals. + +Connect Datadog once as a Datadog credential: choose your site, then add an API key and an application key from **Organization Settings > API Keys and Application Keys**. Sim checks the API key when you save the credential. The application key is checked by the first action that needs it, so a key scoped to only the permissions your workflows use works. Workflows built with the earlier Datadog block keep the keys and site they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/sim/blocks/blocks/datadog.ts b/apps/sim/blocks/blocks/datadog.ts index 7d0ca1d191d..531a2bc065f 100644 --- a/apps/sim/blocks/blocks/datadog.ts +++ b/apps/sim/blocks/blocks/datadog.ts @@ -1,6 +1,7 @@ import { DatadogIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' /** * Normalizes a `switch` sub-block value. Switches serialize their state as the @@ -33,6 +34,8 @@ function datadogNumber(value: unknown): number | undefined { export const DatadogBlock: BlockConfig = { type: 'datadog', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'datadog_v2' }, name: 'Datadog', description: 'Monitor infrastructure, applications, and logs with Datadog', authMode: AuthMode.ApiKey, @@ -2625,3 +2628,18 @@ export const DatadogBlockMeta = { }, ], } as const satisfies BlockMeta + +export const DatadogV2Block: BlockConfig = { + ...DatadogBlock, + type: 'datadog_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + DatadogBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'datadog', title: 'Datadog Credential' }, + // The API key shows in every operation and the application key only where it is sent, + // and the site is optional; one credential carries all three, in every operation. + { condition: null, required: true } + ), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 9c642272d1b..4aa12e3a9ea 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -77,7 +77,7 @@ import { CrunchbaseBlock, CrunchbaseBlockMeta, CrunchbaseV2Block } from '@/block import { CursorBlock, CursorBlockMeta, CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' import { DatabricksBlock, DatabricksBlockMeta, DatabricksV2Block } from '@/blocks/blocks/databricks' -import { DatadogBlock, DatadogBlockMeta } from '@/blocks/blocks/datadog' +import { DatadogBlock, DatadogBlockMeta, DatadogV2Block } from '@/blocks/blocks/datadog' import { DatagmaBlock, DatagmaBlockMeta } from '@/blocks/blocks/datagma' import { DaytonaBlock, DaytonaBlockMeta, DaytonaV2Block } from '@/blocks/blocks/daytona' import { DeploymentsBlock } from '@/blocks/blocks/deployments' @@ -568,6 +568,7 @@ export const BLOCK_REGISTRY: Record = { databricks: DatabricksBlock, databricks_v2: DatabricksV2Block, datadog: DatadogBlock, + datadog_v2: DatadogV2Block, datagma: DatagmaBlock, daytona: DaytonaBlock, daytona_v2: DaytonaV2Block, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 960b11834bb..c5c354a315f 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -443,6 +443,26 @@ describe('withSavedCredential', () => { expect(subBlocks.find((subBlock) => subBlock.id === 'credential')?.required).toBe(true) }) + it('shows the picker in every operation when the successor says so, over secrets shown differently', () => { + const partlyConditional: BlockConfig = { + ...legacy, + subBlocks: legacy.subBlocks.map((subBlock) => + subBlock.id === 'accessKeyId' && subBlock.mode !== 'trigger' + ? { ...subBlock, condition: { field: 'operation', value: 'read' } } + : subBlock + ), + } + + expect(() => withSavedCredential(partlyConditional, picker)).toThrow( + /show or require differently/ + ) + const { subBlocks } = withSavedCredential(partlyConditional, picker, { condition: null }) + const credential = subBlocks.find((subBlock) => subBlock.id === 'credential') + + expect(credential).not.toHaveProperty('condition') + expect(credential?.required).toBe(true) + }) + it('refuses options that would silently do nothing', () => { expect(() => withSavedCredential(legacy, picker, { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a929c3ceb30..a5f423efb15 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -75,8 +75,12 @@ interface SavedCredentialOptions { * endpoints, keyless self-hosted servers) passes a condition or `false`. */ required?: SubBlockConfig['required'] - /** Where the picker shows. Defaults to the replaced secret's own `condition`. */ - condition?: SubBlockConfig['condition'] + /** + * Where the picker shows. Defaults to the replaced secret's own `condition`; + * `null` shows it in every operation, for a block whose replaced fields show + * differently (a key every operation sends beside one only some send). + */ + condition?: SubBlockConfig['condition'] | null } /** @@ -116,7 +120,7 @@ export function withSavedCredential( if (new Set(legacy.subBlocks.filter(isReplaced).map(inherited)).size > 1) { throw new Error(`Block "${legacy.type}": the replaced secrets show or require differently`) } - const condition = options.condition ?? secretCondition + const condition = options.condition === null ? undefined : (options.condition ?? secretCondition) const conditionFields = (value: SubBlockConfig['condition']): string[] => value && typeof value !== 'function' ? [value.field, ...conditionFields(value.and as SubBlockConfig['condition'])] diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 25af9d40e45..0c766474ac0 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -121,6 +121,8 @@ const oauthTokenResponseSchema = z.object({ cloudId: z.string().optional(), domain: z.string().optional(), providerWorkspaceId: z.string().optional(), + /** Datadog — the application key sent beside the API key (`accessToken`). */ + applicationKey: z.string().optional(), realmId: z.string().optional(), quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), authStyle: z.enum(['x-api-token', 'snowflake-workload-identity']).optional(), diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 068c5420174..984a4385406 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -11,6 +11,7 @@ */ import { CROWDSTRIKE_CLOUDS, type CrowdStrikeCloud } from '@/tools/crowdstrike/types' +import { DATADOG_SITES, type DatadogSite } from '@/tools/datadog/types' /** Discriminator stored inside every encrypted client-credential secret blob. */ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const @@ -139,6 +140,7 @@ export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const export const CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID = 'crowdstrike-service-account' as const +export const DATADOG_SERVICE_ACCOUNT_PROVIDER_ID = 'datadog-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -166,6 +168,7 @@ export type ClientCredentialAccountProviderId = | typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID | typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID | typeof CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof DATADOG_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -468,6 +471,19 @@ const CROWDSTRIKE_CLOUD_LABELS: Record = { 'us-gov-2': 'US-GOV-2', } +/** Datadog sites as the legacy block's dropdown names them. */ +const DATADOG_SITE_LABELS: Record = { + 'datadoghq.com': 'US1 (datadoghq.com)', + 'us3.datadoghq.com': 'US3 (us3.datadoghq.com)', + 'us5.datadoghq.com': 'US5 (us5.datadoghq.com)', + 'datadoghq.eu': 'EU (datadoghq.eu)', + 'ap1.datadoghq.com': 'AP1 (ap1.datadoghq.com)', + 'ap2.datadoghq.com': 'AP2 (ap2.datadoghq.com)', + 'uk1.datadoghq.com': 'UK1 (uk1.datadoghq.com)', + 'ddog-gov.com': 'US1-FED (ddog-gov.com)', + 'us2.ddog-gov.com': 'US2-FED (us2.ddog-gov.com)', +} + export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor @@ -500,6 +516,36 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', }, + [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: DATADOG_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Datadog', + connectNoun: 'API key pair', + fields: [ + { + id: 'dataCenter', + label: 'Site', + placeholder: 'Select a site', + secret: false, + options: DATADOG_SITES.map((site) => ({ value: site, label: DATADOG_SITE_LABELS[site] })), + hint: 'The site in the address you sign in to Datadog with.', + }, + { + id: 'clientId', + label: 'API key', + placeholder: 'Paste the API key', + secret: true, + hint: 'Create keys in Datadog under Organization Settings > API Keys and Application Keys.', + }, + { + id: 'clientSecret', + label: 'Application key', + placeholder: 'Paste the application key', + secret: true, + hint: 'Workflows act with this key and its scopes. Datadog checks it on the first request that needs it.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/datadog', + }, [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'CrowdStrike', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts new file mode 100644 index 00000000000..172c839ac19 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts @@ -0,0 +1,98 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintDatadogServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/datadog' + +const FIELDS = { + clientId: 'dd-api-key', + clientSecret: 'dd-application-key', + orgId: '', + dataCenter: 'datadoghq.eu', +} + +const mockFetch = vi.fn() + +describe('Datadog API and application key check', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("validates the API key alone on the site's own API host, never following a redirect", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + requests.push(new Request(input, init)) + return jsonResponse({ valid: true }) + }) + + const result = await mintDatadogServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://api.datadoghq.eu/api/v1/validate') + expect(request.method).toBe('GET') + expect(request.redirect).toBe('error') + expect(request.headers.get('dd-api-key')).toBe('dd-api-key') + expect(request.headers.has('dd-application-key')).toBe(false) + expect(result).toMatchObject({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + expect(JSON.stringify(result.identity)).not.toContain('dd-api-key') + expect(JSON.stringify(result.identity)).not.toContain('dd-application-key') + }) + + it('resolves both keys and the site at run time without calling Datadog', async () => { + const result = await mintDatadogServiceAccountToken(FIELDS, { skipIdentity: true }) + + expect(mockFetch).not.toHaveBeenCalled() + expect(result).toMatchObject({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + }) + + it.each(['', 'datadoghq.co', 'evil.example', 'datadoghq.com@evil.example', 'DATADOGHQ.COM'])( + 'refuses the site %j before sending a key anywhere', + async (dataCenter) => { + await expect(mintDatadogServiceAccountToken({ ...FIELDS, dataCenter })).rejects.toMatchObject( + { code: 'invalid_credentials' } + ) + await expect( + mintDatadogServiceAccountToken({ ...FIELDS, dataCenter }, { skipIdentity: true }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it.each([ + ['clientId', 'key\r\nX-Injected: 1'], + ['clientSecret', 'key\nX-Injected: 1'], + ['clientSecret', ''], + ] as const)('refuses an unusable %s before any request', async (field, value) => { + await expect( + mintDatadogServiceAccountToken({ ...FIELDS, [field]: value }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [403, 'invalid_credentials'], + [401, 'invalid_credentials'], + [429, 'provider_unavailable'], + [500, 'provider_unavailable'], + ])('maps an HTTP %i validate response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ errors: ['Forbidden'] }, { status })) + + await expect(mintDatadogServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('refuses an API key Datadog does not report as valid', async () => { + mockFetch.mockResolvedValue(jsonResponse({ valid: false })) + + await expect(mintDatadogServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts new file mode 100644 index 00000000000..a8f95a140db --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts @@ -0,0 +1,117 @@ +import { toRecord } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + fetchProvider, + requireClientSecret, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import { DATADOG_SITES, type DatadogSite } from '@/tools/datadog/types' +import { datadogApiUrl } from '@/tools/datadog/utils' + +const STEP = 'datadog_key_check' +/** Datadog keys do not expire on their own; the TTL only bounds the local token cache. */ +const SYNTHETIC_TOKEN_TTL_SECONDS = 3600 +const MAX_KEY_BYTES = 1_024 +const MAX_VALIDATE_RESPONSE_BYTES = 16 * 1024 +const FORBIDDEN_KEY_TEXT = /[\u0000- \u007f]/ + +function invalidCredentials(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) +} + +function assertKey(value: string, label: string): void { + if ( + !value || + FORBIDDEN_KEY_TEXT.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_KEY_BYTES + ) { + throw invalidCredentials(`${label} is invalid`) + } +} + +function isDatadogSite(value: unknown): value is DatadogSite { + return typeof value === 'string' && (DATADOG_SITES as readonly string[]).includes(value) +} + +/** + * Resolves a saved Datadog key pair. Datadog authenticates every request with + * two static headers, so nothing is minted: the API key travels as the + * credential's token, the application key beside it, and the site (one of + * Datadog's published regional sites) as its domain, which the tools build + * their host from. + * + * Connect-time verification calls `GET https://api.{site}/api/v1/validate` + * with the API key alone, the documented check that answers 403 for an + * invalid API key. The application key is deliberately not probed: Datadog + * publishes no reference for an application-key check, and any other read + * would refuse a correctly scoped key with a permission 403. Datadog reports a + * bad application key on the first request that needs one. + * + * @see https://docs.datadoghq.com/api/latest/authentication/#validate-api-key + */ +export async function mintDatadogServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const apiKey = fields.clientId.trim() + const applicationKey = requireClientSecret(fields.clientSecret, STEP, 'Datadog').trim() + assertKey(apiKey, 'API key') + assertKey(applicationKey, 'application key') + const site = fields.dataCenter?.trim() + if (!isDatadogSite(site)) throw invalidCredentials('unknown Datadog site') + + const resolved = { + accessToken: apiKey, + applicationKey, + domain: site, + expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS, + } + if (options?.skipIdentity) return resolved + + const response = await fetchProvider( + datadogApiUrl(site, '/api/v1/validate'), + { + method: 'GET', + redirect: 'error', + signal: options?.signal, + headers: { Accept: 'application/json', 'DD-API-KEY': apiKey }, + }, + STEP + ) + await throwForProviderResponse(response, STEP, { site }) + + let body: Record + try { + body = toRecord( + await readResponseJsonWithLimit(response, { + maxBytes: MAX_VALIDATE_RESPONSE_BYTES, + label: 'Datadog validate response', + signal: options?.signal, + }) + ) + } catch { + options?.signal?.throwIfAborted() + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid or oversized response', + }) + } + if (body.valid !== true) throw invalidCredentials('Datadog did not report the API key valid') + + return { + ...resolved, + identity: { + displayName: `Datadog ${site}`, + principal: null, + auditMetadata: { datadogSite: site }, + storedMetadata: { site }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 8d651cf6b34..77c07a8a7db 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -3,6 +3,7 @@ import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, type ClientCredentialAccountProviderId, CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, + DATADOG_SERVICE_ACCOUNT_PROVIDER_ID, DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID, FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -22,6 +23,7 @@ import { } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' +import { mintDatadogServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/datadog' import { mintDocuSignServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/docusign' import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' @@ -117,11 +119,16 @@ export interface ClientCredentialAccountMintResult { */ apiDomain?: string /** - * The region, site, or tenant the token is bound to (a CrowdStrike cloud), - * forwarded to tools as the credential's `domain`, which fills the tool param - * that picks the provider host. + * The region, site, or tenant the token is bound to (a CrowdStrike cloud or + * Datadog site), forwarded to tools as the credential's `domain`, which fills + * the tool param that picks the provider host. */ domain?: string + /** + * Datadog only — the application key every Datadog read sends beside the API + * key (`accessToken`). A secret, handled like the token. + */ + applicationKey?: string /** Scopes granted to the app, when the provider reports them. */ grantedScopes?: string[] identity?: ClientCredentialAccountIdentity @@ -170,6 +177,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID]: mintHelpScoutServiceAccountToken, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: mintDocuSignServiceAccountToken, [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: mintCrowdStrikeServiceAccountToken, + [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: mintDatadogServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/oauth/credential-service-cancellation.test.ts b/apps/sim/lib/oauth/credential-service-cancellation.test.ts index b2adb169eb9..c51185a8fbf 100644 --- a/apps/sim/lib/oauth/credential-service-cancellation.test.ts +++ b/apps/sim/lib/oauth/credential-service-cancellation.test.ts @@ -86,16 +86,20 @@ describe('client credential cancellation isolation', () => { }) describe('client credential token cache', () => { - it('serves the region a token was minted for along with the cached token', async () => { + it('serves the region and application key a token was minted with along with the cached token', async () => { const mint = vi.fn() - mint.mockResolvedValue({ ...token, domain: 'eu-1' }) + mint.mockResolvedValue({ ...token, domain: 'eu-1', applicationKey: 'application-key' }) vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint) for (let call = 0; call < 2; call++) { queueTableRows(credential, [storedCredential]) await expect( resolveServiceAccountToken('ramp-cached-region', RAMP_SERVICE_ACCOUNT_PROVIDER_ID) - ).resolves.toMatchObject({ accessToken: token.accessToken, domain: 'eu-1' }) + ).resolves.toMatchObject({ + accessToken: token.accessToken, + domain: 'eu-1', + applicationKey: 'application-key', + }) } expect(mint).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 6fc0138e2dd..89a7e8e53ac 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -547,6 +547,8 @@ export interface ServiceAccountTokenResult { /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ domain?: string providerWorkspaceId?: string + /** Datadog only — the application key sent beside the API key (`accessToken`). */ + applicationKey?: string /** Salesforce or NetSuite — the provider origin the token must be used against. */ instanceUrl?: string /** @@ -738,6 +740,7 @@ async function resolveClientCredentialAccountToken( instanceUrl: mint.instanceUrl, apiDomain: mint.apiDomain, domain: mint.domain, + applicationKey: mint.applicationKey, } if (!usesSharedToken) clientCredentialTokenCache.set(cacheIdentity, { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2e86fc48e4f..f2975fd379e 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -40,6 +40,7 @@ import { CrunchbaseIcon, CursorIcon, DatabricksIcon, + DatadogIcon, DaytonaIcon, DevinIcon, DiscordIcon, @@ -1660,6 +1661,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'fivetran', }, + datadog: { + name: 'Datadog', + icon: DatadogIcon, + services: { + datadog: { + name: 'Datadog', + description: 'Monitor infrastructure, applications, and logs with Datadog.', + providerId: 'datadog', + serviceAccountProviderId: 'datadog-service-account', + authType: 'service_account', + icon: DatadogIcon, + baseProviderIcon: DatadogIcon, + scopes: [], + }, + }, + defaultService: 'datadog', + }, crowdstrike: { name: 'CrowdStrike', icon: CrowdStrikeIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e7bddcca046..e191ff4fea4 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -582,12 +582,46 @@ describe('ordinary tool credential binding', () => { expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() }) + it('releases the application key a Datadog key pair resolves beside its API key', async () => { + mockGetToolMetadata.mockReturnValue({ + id: 'fixture-tool', + params: {}, + oauth: { required: false, provider: 'datadog', credentialKind: 'service-account' }, + }) + mockResolveOAuthAccountId.mockResolvedValue({ + ...serviceAccount, + providerId: 'datadog-service-account', + }) + mockResolveServiceAccountToken.mockResolvedValue({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + + expect( + await resolveCredentialAccessToken({ + requestId: 'binding-test', + credentialId: 'credential-1', + toolId: 'fixture-tool', + authenticate, + }) + ).toMatchObject({ + ok: true, + token: { + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }, + }) + }) + it.each([ ['oracle-fusion', 'oracle-fusion-service-account'], ['vanta', 'vanta-service-account'], ['ramp', 'ramp-service-account'], ['greenhouse', 'greenhouse-service-account'], ['crowdstrike', 'crowdstrike-service-account'], + ['datadog', 'datadog-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 60fa5bb0883..2d1f014bb26 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -437,6 +437,7 @@ export async function resolveCredentialToken( cloudId: result.cloudId, domain: result.domain, providerWorkspaceId: result.providerWorkspaceId, + applicationKey: result.applicationKey, instanceUrl: result.instanceUrl, apiDomain: result.apiDomain, authStyle: result.authStyle, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 5daf8eed9ce..4d64f237ee0 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -115,6 +115,7 @@ export type OAuthProvider = | 'ramp' | 'helpscout' | 'fivetran' + | 'datadog' | 'crowdstrike' | 'vanta' | 'hubspot' @@ -292,6 +293,7 @@ export type OAuthService = | 'ramp' | 'helpscout' | 'fivetran' + | 'datadog' | 'crowdstrike' | 'vanta' | 'hubspot' diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 5d8f23345cf..9b1ce4b81d1 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -33,6 +33,7 @@ import { CrowdStrikeBlock, CrowdStrikeV2Block } from '@/blocks/blocks/crowdstrik import { CrunchbaseBlock, CrunchbaseV2Block } from '@/blocks/blocks/crunchbase' import { CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DatabricksBlock, DatabricksV2Block } from '@/blocks/blocks/databricks' +import { DatadogBlock, DatadogV2Block } from '@/blocks/blocks/datadog' import { DaytonaBlock, DaytonaV2Block } from '@/blocks/blocks/daytona' import { DevinBlock, DevinV2Block } from '@/blocks/blocks/devin' import { DiscordBlock, DiscordV2Block } from '@/blocks/blocks/discord' @@ -142,6 +143,8 @@ const VERSIONS: Array<{ secretIds: readonly string[] /** Replaced connection fields the legacy block never required (a host defaulting to the vendor's). */ optionalIds?: readonly string[] + /** An operation that shows every replaced field, for a legacy block that shows some only there. */ + legacyOperation?: string }> = [ { legacy: AppConfigBlock, @@ -349,6 +352,13 @@ const VERSIONS: Array<{ { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, + { + legacy: DatadogBlock, + successor: DatadogV2Block, + secretIds: ['apiKey', 'applicationKey', 'site'], + optionalIds: ['applicationKey', 'site'], + legacyOperation: 'datadog_get_monitor', + }, { legacy: CrowdStrikeBlock, successor: CrowdStrikeV2Block, @@ -419,27 +429,35 @@ function missingRequiredFields(block: BlockState): string { } } -describe.each(VERSIONS)('$legacy.type legacy block', ({ legacy, secretIds, optionalIds = [] }) => { - beforeEach(() => { - vi.spyOn(blocks, 'getBlock').mockReturnValue(legacy) - }) +describe.each(VERSIONS)( + '$legacy.type legacy block', + ({ legacy, secretIds, optionalIds = [], legacyOperation }) => { + beforeEach(() => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(legacy) + }) - it('serializes its inline secrets exactly as before', () => { - const params = serialize(buildBlock(legacy, valuesFor(secretIds, 'inline'))) + it('serializes its inline secrets exactly as before', () => { + const params = serialize( + buildBlock(legacy, { + ...(legacyOperation ? { operation: legacyOperation } : {}), + ...valuesFor(secretIds, 'inline'), + }) + ) - for (const id of secretIds) expect(params[id]).toBe(`inline-${id}`) - expect(params.oauthCredential).toBeUndefined() - }) + for (const id of secretIds) expect(params[id]).toBe(`inline-${id}`) + expect(params.oauthCredential).toBeUndefined() + }) - it('still requires its inline secrets', () => { - const requiredIds = secretIds.filter((id) => !optionalIds.includes(id)) - const message = missingRequiredFields(buildBlock(legacy, {})) - const titles = fieldTitles(legacy, requiredIds) + it('still requires its inline secrets', () => { + const requiredIds = secretIds.filter((id) => !optionalIds.includes(id)) + const message = missingRequiredFields(buildBlock(legacy, {})) + const titles = fieldTitles(legacy, requiredIds) - expect(titles).toHaveLength(requiredIds.length) - for (const title of titles) expect(message).toContain(title) - }) -}) + expect(titles).toHaveLength(requiredIds.length) + for (const title of titles) expect(message).toContain(title) + }) + } +) describe.each(VERSIONS)('$successor.type credential-only block', ({ successor, secretIds }) => { beforeEach(() => { diff --git a/apps/sim/tools/datadog/add_incident_todo.ts b/apps/sim/tools/datadog/add_incident_todo.ts index 85a41923c0e..e3443885e85 100644 --- a/apps/sim/tools/datadog/add_incident_todo.ts +++ b/apps/sim/tools/datadog/add_incident_todo.ts @@ -6,6 +6,7 @@ import { datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const addIncidentTodoTool: ToolConfig = { @@ -14,6 +15,7 @@ export const addIncidentTodoTool: ToolConfig = { @@ -7,6 +8,7 @@ export const cancelDowntimeTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createDashboardTool: ToolConfig = { @@ -16,6 +17,7 @@ export const createDowntimeTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createEventTool: ToolConfig description: 'Post an event to the Datadog event stream. Use for deployment notifications, alerts, or any significant occurrences.', version: '1.0.0', + oauth: DATADOG_API_KEY_CREDENTIAL_OAUTH, params: { title: { diff --git a/apps/sim/tools/datadog/create_incident.ts b/apps/sim/tools/datadog/create_incident.ts index 5e6a2d71148..50daeaa917f 100644 --- a/apps/sim/tools/datadog/create_incident.ts +++ b/apps/sim/tools/datadog/create_incident.ts @@ -6,6 +6,7 @@ import { parseJsonParam, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const createIncidentTool: ToolConfig = { @@ -14,6 +15,7 @@ export const createIncidentTool: ToolConfig = { @@ -8,6 +9,7 @@ export const createMonitorTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createSloTool: ToolConfig = { description: 'Create a service level objective from a metric query, monitors, or a time-slice condition.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { name: { diff --git a/apps/sim/tools/datadog/delete_dashboard.ts b/apps/sim/tools/datadog/delete_dashboard.ts index ca35597fd33..9297589ebb0 100644 --- a/apps/sim/tools/datadog/delete_dashboard.ts +++ b/apps/sim/tools/datadog/delete_dashboard.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const deleteDashboardTool: ToolConfig = { @@ -12,6 +13,7 @@ export const deleteDashboardTool: ToolConfig = { @@ -13,6 +14,7 @@ export const deleteSloTool: ToolConfig = { description: 'Permanently delete a service level objective. Datadog returns a conflict when the SLO is still referenced by a dashboard.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { sloId: { diff --git a/apps/sim/tools/datadog/get_browser_synthetics_results.ts b/apps/sim/tools/datadog/get_browser_synthetics_results.ts index afe8ad6521a..0c32409ae14 100644 --- a/apps/sim/tools/datadog/get_browser_synthetics_results.ts +++ b/apps/sim/tools/datadog/get_browser_synthetics_results.ts @@ -9,6 +9,7 @@ import { datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getBrowserSyntheticsResultsTool: ToolConfig< @@ -20,6 +21,7 @@ export const getBrowserSyntheticsResultsTool: ToolConfig< description: 'Get the latest result summaries (up to the last 150 runs) for a Synthetic browser test, including step counts and errors.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { publicId: { diff --git a/apps/sim/tools/datadog/get_dashboard.ts b/apps/sim/tools/datadog/get_dashboard.ts index 9f94918077e..ca7c0899e37 100644 --- a/apps/sim/tools/datadog/get_dashboard.ts +++ b/apps/sim/tools/datadog/get_dashboard.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getDashboardTool: ToolConfig = { @@ -12,6 +13,7 @@ export const getDashboardTool: ToolConfig = { @@ -14,6 +15,7 @@ export const getIncidentTool: ToolConfig description: 'Get the details of a single incident by ID. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { incidentId: { diff --git a/apps/sim/tools/datadog/get_monitor.ts b/apps/sim/tools/datadog/get_monitor.ts index 7d2ace45106..1414c4e1330 100644 --- a/apps/sim/tools/datadog/get_monitor.ts +++ b/apps/sim/tools/datadog/get_monitor.ts @@ -1,5 +1,6 @@ import type { GetMonitorParams, GetMonitorResponse } from '@/tools/datadog/types' import { datadogErrorMessage, datadogPathSegment, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getMonitorTool: ToolConfig = { @@ -7,6 +8,7 @@ export const getMonitorTool: ToolConfig = name: 'Datadog Get Monitor', description: 'Retrieve details of a specific monitor by ID.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { monitorId: { diff --git a/apps/sim/tools/datadog/get_security_signal.ts b/apps/sim/tools/datadog/get_security_signal.ts index 3f8527445de..c3acf3d0254 100644 --- a/apps/sim/tools/datadog/get_security_signal.ts +++ b/apps/sim/tools/datadog/get_security_signal.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getSecuritySignalTool: ToolConfig = @@ -14,6 +15,7 @@ export const getSecuritySignalTool: ToolConfig = { @@ -12,6 +13,7 @@ export const getSloTool: ToolConfig = { name: 'Datadog Get SLO', description: 'Get the configuration of a single service level objective by ID.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { sloId: { diff --git a/apps/sim/tools/datadog/get_slo_history.ts b/apps/sim/tools/datadog/get_slo_history.ts index a047d95d254..343902e356b 100644 --- a/apps/sim/tools/datadog/get_slo_history.ts +++ b/apps/sim/tools/datadog/get_slo_history.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getSloHistoryTool: ToolConfig = { @@ -13,6 +14,7 @@ export const getSloHistoryTool: ToolConfig = @@ -14,6 +15,7 @@ export const getSyntheticsTestTool: ToolConfig = { @@ -8,6 +9,7 @@ export const listDashboardsTool: ToolConfig = { @@ -12,6 +13,7 @@ export const listDowntimesTool: ToolConfig = { @@ -18,6 +19,7 @@ export const listIncidentsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listMonitorsTool: ToolConfig = @@ -13,6 +14,7 @@ export const listSecurityRulesTool: ToolConfig = { @@ -13,6 +14,7 @@ export const listServicesTool: ToolConfig = { @@ -8,6 +9,7 @@ export const listSlosTool: ToolConfig = { description: 'List service level objectives, optionally filtered by IDs, name, tags, or underlying metrics query.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { ids: { diff --git a/apps/sim/tools/datadog/list_synthetics_tests.ts b/apps/sim/tools/datadog/list_synthetics_tests.ts index fef037aef29..4df58a5bb43 100644 --- a/apps/sim/tools/datadog/list_synthetics_tests.ts +++ b/apps/sim/tools/datadog/list_synthetics_tests.ts @@ -4,6 +4,7 @@ import type { SyntheticsTestData, } from '@/tools/datadog/types' import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const listSyntheticsTestsTool: ToolConfig< @@ -14,6 +15,7 @@ export const listSyntheticsTestsTool: ToolConfig< name: 'Datadog List Synthetic Tests', description: 'List all Synthetic tests (API, browser, and mobile) with their current status.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { pageSize: { diff --git a/apps/sim/tools/datadog/mute_monitor.ts b/apps/sim/tools/datadog/mute_monitor.ts index 04c212938d2..75417da13c1 100644 --- a/apps/sim/tools/datadog/mute_monitor.ts +++ b/apps/sim/tools/datadog/mute_monitor.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const muteMonitorTool: ToolConfig = { @@ -13,6 +14,7 @@ export const muteMonitorTool: ToolConfig description: 'Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to reverse it, or schedule a downtime instead when you want a planned, auditable maintenance window.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { monitorId: { diff --git a/apps/sim/tools/datadog/query_logs.ts b/apps/sim/tools/datadog/query_logs.ts index 60a02dc6898..9efef8bc125 100644 --- a/apps/sim/tools/datadog/query_logs.ts +++ b/apps/sim/tools/datadog/query_logs.ts @@ -5,6 +5,7 @@ import type { QueryLogsResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const queryLogsTool: ToolConfig = { @@ -13,6 +14,7 @@ export const queryLogsTool: ToolConfig = { description: 'Search and retrieve logs from Datadog. Use for troubleshooting, analysis, or monitoring.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { query: { diff --git a/apps/sim/tools/datadog/query_timeseries.ts b/apps/sim/tools/datadog/query_timeseries.ts index 7ec5017ca2b..5a80464ecfe 100644 --- a/apps/sim/tools/datadog/query_timeseries.ts +++ b/apps/sim/tools/datadog/query_timeseries.ts @@ -4,6 +4,7 @@ import type { QueryTimeseriesResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const queryTimeseriesTool: ToolConfig = { @@ -12,6 +13,7 @@ export const queryTimeseriesTool: ToolConfig = { @@ -12,6 +13,7 @@ export const searchSpansTool: ToolConfig name: 'Datadog Search Spans', description: 'Search indexed APM spans using the span query syntax, with cursor pagination.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { query: { diff --git a/apps/sim/tools/datadog/send_logs.ts b/apps/sim/tools/datadog/send_logs.ts index a31e56749c6..b1a0118fa78 100644 --- a/apps/sim/tools/datadog/send_logs.ts +++ b/apps/sim/tools/datadog/send_logs.ts @@ -1,6 +1,7 @@ import { filterUndefined } from '@sim/utils/object' import type { LogEntry, SendLogsParams, SendLogsResponse } from '@/tools/datadog/types' import { datadogErrorMessage, parseJsonParam, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_API_KEY_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const sendLogsTool: ToolConfig = { @@ -8,6 +9,7 @@ export const sendLogsTool: ToolConfig = { name: 'Datadog Send Logs', description: 'Send log entries to Datadog for centralized logging and analysis.', version: '1.0.0', + oauth: DATADOG_API_KEY_CREDENTIAL_OAUTH, params: { logs: { diff --git a/apps/sim/tools/datadog/submit_metrics.ts b/apps/sim/tools/datadog/submit_metrics.ts index 39723fba90c..b6e33330635 100644 --- a/apps/sim/tools/datadog/submit_metrics.ts +++ b/apps/sim/tools/datadog/submit_metrics.ts @@ -5,6 +5,7 @@ import type { SubmitMetricsResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, parseJsonParam, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_API_KEY_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' /** @@ -24,6 +25,7 @@ export const submitMetricsTool: ToolConfig = { @@ -13,6 +14,7 @@ export const unmuteMonitorTool: ToolConfig = { @@ -15,6 +16,7 @@ export const updateIncidentTool: ToolConfig = { @@ -8,6 +9,7 @@ export const updateSloTool: InternalToolConfig { }) }) + describe('saved key-pair credentials (application key)', () => { + const toolId = 'test_saved_key_pair_credential' + const keyPairTool = { + id: toolId, + name: 'Saved Key Pair Fixture', + description: 'Takes inline keys or a saved key-pair credential', + version: '1.0.0', + oauth: tokenCredentialOAuth('coda', { + apiKey: 'accessToken', + applicationKey: 'applicationKey', + site: 'domain', + }), + params: { + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + applicationKey: { type: 'string', required: true, visibility: 'user-only' }, + site: { type: 'string', required: false, visibility: 'user-only' }, + }, + request: { + url: (params: Record) => `https://api.${params.site}/api/v1/monitor`, + method: 'GET' as const, + headers: (params: Record) => ({ + 'DD-API-KEY': String(params.apiKey), + 'DD-APPLICATION-KEY': String(params.applicationKey), + }), + }, + transformResponse: async () => ({ success: true, output: {} }), + } + + beforeEach(() => { + ;(tools as Record)[toolId] = keyPairTool + }) + afterEach(() => { + Reflect.deleteProperty(tools, toolId) + }) + + it('sends the saved application key beside the saved API key, overriding caller values', async () => { + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'saved-api-key', + applicationKey: 'saved-application-key', + credentialType: 'service_account', + domain: 'datadoghq.eu', + }) + + const result = await executeTool(toolId, { + credential: 'saved-credential', + applicationKey: 'caller-application-key', + site: 'attacker.example.net', + }) + + expect(result.success).toBe(true) + expect(mockSecureFetchWithPinnedIP).toHaveBeenLastCalledWith( + 'https://api.datadoghq.eu/api/v1/monitor', + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + 'dd-api-key': 'saved-api-key', + 'dd-application-key': 'saved-application-key', + }), + }) + ) + }) + + it('fails closed when the credential carries no application key', async () => { + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'saved-api-key', + credentialType: 'service_account', + domain: 'datadoghq.eu', + }) + + const result = await executeTool(toolId, { + credential: 'saved-credential', + applicationKey: 'caller-application-key', + }) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('needs a saved credential with a Datadog application key'), + }) + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + }) + it.each(['oauth', 'managed_oauth', undefined])( 'rejects %s credentials for Slack Lists before calling Slack', async (credentialType) => { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f51e68b15f6..e1f1c7efac0 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -603,6 +603,7 @@ const CREDENTIAL_FIELD_LABELS: Record = { accessToken: 'token', domain: 'host', providerWorkspaceId: 'workspace ID', + applicationKey: 'Datadog application key', } /** @@ -2069,6 +2070,7 @@ async function executeToolImplementation( await registerAssistantCredentialSecrets(resolvedSecretTraceRegistry, [ data.accessToken, data.idToken, + data.applicationKey, ]) } authorizedServiceAccountCredential = diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index d0ddfbb80a8..0f8c7613104 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -128,6 +128,23 @@ export const CROWDSTRIKE_CREDENTIAL_OAUTH = clientCredentialOAuth( ['clientId', 'clientSecret'] ) +/** + * Credential policy for every Datadog tool that sends both keys. A saved + * Datadog key pair fills the API key, the application key, and the site the + * tools build their host from. + */ +export const DATADOG_CREDENTIAL_OAUTH = tokenCredentialOAuth('datadog', { + apiKey: 'accessToken', + applicationKey: 'applicationKey', + site: 'domain', +}) + +/** Credential policy for the Datadog intake tools, which send only the API key. */ +export const DATADOG_API_KEY_CREDENTIAL_OAUTH = tokenCredentialOAuth('datadog', { + apiKey: 'accessToken', + site: 'domain', +}) + /** Token credential policies for tools that take an inline key or a saved token credential. */ export const AFFINITY_CREDENTIAL_OAUTH = tokenCredentialOAuth('affinity', { apiKey: 'accessToken' }) export const AGENTMAIL_CREDENTIAL_OAUTH = tokenCredentialOAuth('agentmail', { @@ -385,6 +402,7 @@ export const SAVED_CREDENTIAL_OAUTH = { crunchbase: CRUNCHBASE_CREDENTIAL_OAUTH, cursor: CURSOR_CREDENTIAL_OAUTH, databricks: DATABRICKS_CREDENTIAL_OAUTH, + datadog: DATADOG_CREDENTIAL_OAUTH, daytona: DAYTONA_CREDENTIAL_OAUTH, devin: DEVIN_CREDENTIAL_OAUTH, discord: DISCORD_CREDENTIAL_OAUTH, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 1772a91e55e..43b9f98a543 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -128,7 +128,12 @@ export interface ToolResponse { } /** A field of a resolved saved credential that can fill a tool param. */ -export type CredentialParamSource = 'accessToken' | 'domain' | 'providerWorkspaceId' +export type CredentialParamSource = + | 'accessToken' + | 'domain' + | 'providerWorkspaceId' + /** Datadog's application key, a secret sent beside the API key. */ + | 'applicationKey' export interface OAuthConfig { required: boolean // Whether this tool requires OAuth authentication From 187a3dd65a687ea6871d0a32955a3b12b986f317 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:49:28 -0800 Subject: [PATCH 34/83] fix(aws): bound S3 presigned URLs by the role session's duration, not its time left The presign guard compared the requested expiry with the seconds left before the STS session ended, floored. A session is minted for each call, so by the time the guard runs the STS round trip has used part of it and a default 3600s session reports 3599s left, which refused the default 3600s expiry for every saved assume_role or oidc_role credential on default settings. Resolved sessions now carry the duration Sim requested from STS, and the guard refuses only an expiry longer than that duration. The reported expiresAt is clamped to the session's end, so it is never later than the time the URL stops working. The session lookup that the signer and the guard share is one helper. --- .../aws-service-account.server.test.ts | 42 ++++++++++++------- .../credentials/aws-service-account.server.ts | 12 ++++-- apps/sim/lib/internal/aws/credentials.ts | 31 ++++++++------ apps/sim/lib/internal/s3/operations.test.ts | 12 ++++++ apps/sim/lib/internal/s3/operations.ts | 31 +++++++++----- 5 files changed, 86 insertions(+), 42 deletions(-) diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts index 2f54a38e275..9479f69c189 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -165,21 +165,33 @@ describe("resolving a run's AWS session", () => { beforeEach(() => hoisted.send.mockReset()) it.each([ - { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT }, { - authType: 'assume_role', - roleArn: ROLE_ARN, - accessKeyId: 'AKIAEXAMPLEKEY01', - secretAccessKey: 'stored-secret', + config: { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT }, + durationSeconds: 3600, }, - ] as const)('reports when a $authType session ends', async (config) => { - hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) - - await expect(resolveAwsSessionCredentials(config, owner, options)).resolves.toEqual({ - accessKeyId: 'ASIATEMP', - secretAccessKey: 'secret', - sessionToken: 'token', - expiration, - }) - }) + { + config: { + authType: 'assume_role', + roleArn: ROLE_ARN, + accessKeyId: 'AKIAEXAMPLEKEY01', + secretAccessKey: 'stored-secret', + sessionDurationSeconds: 900, + }, + durationSeconds: 900, + }, + ] as const)( + 'reports how long a $config.authType session lasts and when it ends', + async ({ config, durationSeconds }) => { + hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) + + await expect(resolveAwsSessionCredentials(config, owner, options)).resolves.toEqual({ + accessKeyId: 'ASIATEMP', + secretAccessKey: 'secret', + sessionToken: 'token', + durationSeconds, + expiration, + }) + expect(hoisted.send.mock.calls[0][0].input.DurationSeconds).toBe(durationSeconds) + } + ) }) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index b75d152572e..6bb817a7edb 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -127,6 +127,8 @@ export interface AwsSessionCredentials { accessKeyId: string secretAccessKey: string sessionToken?: string + /** The STS session length Sim requested; long-lived access keys have none. */ + durationSeconds?: number /** When an STS session ends; long-lived access keys have none. */ expiration?: Date } @@ -148,7 +150,10 @@ export function buildAwsRoleSessionName(label: string | undefined): string { return `sim-${cleaned || 'credential'}` } -function toSessionCredentials(credentials: Credentials | undefined): AwsSessionCredentials { +function toSessionCredentials( + credentials: Credentials | undefined, + durationSeconds: number +): AwsSessionCredentials { if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) { throw new AwsCredentialError('AWS STS returned no credentials') } @@ -156,6 +161,7 @@ function toSessionCredentials(credentials: Credentials | undefined): AwsSessionC accessKeyId: credentials.AccessKeyId, secretAccessKey: credentials.SecretAccessKey, sessionToken: credentials.SessionToken, + durationSeconds, ...(credentials.Expiration ? { expiration: credentials.Expiration } : {}), } } @@ -190,7 +196,7 @@ export async function resolveAwsSessionCredentials( }), { abortSignal: options.signal } ) - return toSessionCredentials(response.Credentials) + return toSessionCredentials(response.Credentials, durationSeconds) } const webIdentityToken = await mintWorkloadIdentityToken({ subject: config.subject, @@ -216,7 +222,7 @@ export async function resolveAwsSessionCredentials( }), { abortSignal: options.signal } ) - return toSessionCredentials(response.Credentials) + return toSessionCredentials(response.Credentials, durationSeconds) } /** The auth mode a decrypted AWS secret holds, or undefined when it is not one. */ diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index 3f67af95011..95b72d4acc8 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -39,28 +39,33 @@ import type { const sessionStorage = new AsyncLocalStorage() +/** The resolved STS session that signs for `accessKeyId`, if this call has one. */ +function sessionFor(accessKeyId: string): AwsSessionCredentials | undefined { + const session = sessionStorage.getStore() + return session?.sessionToken && session.accessKeyId === accessKeyId ? session : undefined +} + /** SDK credentials for a client built from an operation's key fields. */ export function awsClientCredentials( accessKeyId: string, secretAccessKey: string ): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } { - const session = sessionStorage.getStore() - if (session?.sessionToken && session.accessKeyId === accessKeyId) { - return { accessKeyId, secretAccessKey, sessionToken: session.sessionToken } - } - return { accessKeyId, secretAccessKey } + const sessionToken = sessionFor(accessKeyId)?.sessionToken + return sessionToken + ? { accessKeyId, secretAccessKey, sessionToken } + : { accessKeyId, secretAccessKey } } /** - * When the STS session behind `accessKeyId` ends, or undefined for long-lived - * keys. Anything signed with a session, like an S3 presigned URL, stops - * working at this time whatever expiry it asked for. + * How long the STS session behind `accessKeyId` lasts and when it ends, or + * undefined for long-lived keys. Anything signed with a session, like an S3 + * presigned URL, stops working when it ends whatever expiry it asked for. */ -export function awsSessionExpiration(accessKeyId: string): Date | undefined { - const session = sessionStorage.getStore() - return session?.sessionToken && session.accessKeyId === accessKeyId - ? session.expiration - : undefined +export function awsSessionLifetime( + accessKeyId: string +): Pick | undefined { + const session = sessionFor(accessKeyId) + return session && { durationSeconds: session.durationSeconds, expiration: session.expiration } } /** diff --git a/apps/sim/lib/internal/s3/operations.test.ts b/apps/sim/lib/internal/s3/operations.test.ts index 1fb7317b81f..edd6899adfc 100644 --- a/apps/sim/lib/internal/s3/operations.test.ts +++ b/apps/sim/lib/internal/s3/operations.test.ts @@ -86,6 +86,7 @@ describe('S3 presigned URLs', () => { accessKeyId: 'ASIASESSIONKEY', secretAccessKey: 'session-secret', sessionToken: 'session-token', + durationSeconds: 3600, expiration: new Date(NOW.getTime() + 3600 * 1000), } const presign = (accessKeyId: string, expiresIn: number) => @@ -119,6 +120,17 @@ describe('S3 presigned URLs', () => { expect(mocks.getSignedUrl).not.toHaveBeenCalled() }) + it('allows the full session duration after the time spent minting the session', async () => { + vi.setSystemTime(NOW.getTime() + 750) + + const result = await runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 3600)) + + expect(mocks.getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + expiresIn: 3600, + }) + expect(result.output.expiresAt).toBe(SESSION.expiration.toISOString()) + }) + it('reports an expiry the role session outlives', async () => { const result = await runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 3600)) diff --git a/apps/sim/lib/internal/s3/operations.ts b/apps/sim/lib/internal/s3/operations.ts index c7d69f7185a..56a82eb2aff 100644 --- a/apps/sim/lib/internal/s3/operations.ts +++ b/apps/sim/lib/internal/s3/operations.ts @@ -28,7 +28,7 @@ import type { AwsS3ListObjectsBody } from '@/lib/api/contracts/tools/aws/s3-list import type { AwsS3PresignedUrlBody } from '@/lib/api/contracts/tools/aws/s3-presigned-url' import type { AwsS3PutObjectBody } from '@/lib/api/contracts/tools/aws/s3-put-object' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { awsSessionExpiration } from '@/lib/internal/aws/credentials' +import { awsSessionLifetime } from '@/lib/internal/aws/credentials' import { createS3Client, type S3ConnectionConfig } from '@/lib/internal/s3/client' import { S3OperationError } from '@/lib/internal/s3/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -281,18 +281,27 @@ export async function executeS3ListObjects(input: AwsS3ListObjectsBody, signal?: }) } +/** + * A URL signed with a saved role's STS session stops working when that session + * ends. The session is minted for this call, so its full duration is the + * longest expiry the URL can keep; comparing against the duration rather than + * the time left keeps mint latency and clock skew from refusing the default + * expiry on a default-length session. + */ export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal?: AbortSignal) { const now = Date.now() - const sessionExpiration = awsSessionExpiration(input.accessKeyId) - if (sessionExpiration) { - const sessionSeconds = Math.floor((sessionExpiration.getTime() - now) / 1000) - if (input.expiresIn > sessionSeconds) { - throw new S3OperationError( - `This presigned URL would stop working when the AWS role session that signs it ends, in ${sessionSeconds} seconds. Set an expiry of ${sessionSeconds} seconds or less, or give the credential a longer session duration.`, - 400 - ) - } + const session = awsSessionLifetime(input.accessKeyId) + const sessionSeconds = session?.durationSeconds + if (sessionSeconds !== undefined && input.expiresIn > sessionSeconds) { + throw new S3OperationError( + `This presigned URL would stop working when the AWS role session that signs it ends, after ${sessionSeconds} seconds. Set an expiry of ${sessionSeconds} seconds or less, or give the credential a longer session duration.`, + 400 + ) } + const requestedEnd = now + input.expiresIn * 1000 + const expiresAt = session?.expiration + ? Math.min(requestedEnd, session.expiration.getTime()) + : requestedEnd return withS3Client(input, async (client) => { signal?.throwIfAborted() const command = @@ -311,7 +320,7 @@ export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal url, method: input.method, expiresIn: input.expiresIn, - expiresAt: new Date(now + input.expiresIn * 1000).toISOString(), + expiresAt: new Date(expiresAt).toISOString(), }, } }) From 3f04e90e2ad3bdd19580d6c82ce8d3953040b4f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:49:29 -0800 Subject: [PATCH 35/83] fix(aws): classify wrapped STS socket failures as transient with the shared network-error helper isTransientAwsFailure kept its own list of socket codes and checked only the top-level code, so a socket failure the SDK request handler wrapped in a cause, or an undici code, was reported as a 403 denial instead of a 503. It now uses isRetryableNetworkError, which walks the cause chain and errno. --- apps/sim/lib/internal/aws/credentials.test.ts | 7 +++++++ apps/sim/lib/internal/aws/credentials.ts | 13 ++----------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/internal/aws/credentials.test.ts b/apps/sim/lib/internal/aws/credentials.test.ts index b0713431012..1e3319026f3 100644 --- a/apps/sim/lib/internal/aws/credentials.test.ts +++ b/apps/sim/lib/internal/aws/credentials.test.ts @@ -178,6 +178,13 @@ describe('withAwsCredential', () => { Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }), 503, ], + [ + 'the request handler wrapped a dropped socket', + new Error('Request failed', { + cause: Object.assign(new Error('other side closed'), { code: 'UND_ERR_SOCKET' }), + }), + 503, + ], ['the role denies the session', stsFailure('AccessDenied', 403), 403], ['STS rejects the identity token', sdkFailure(InvalidIdentityTokenException), 403], ['the identity token expired', sdkFailure(ExpiredTokenException), 403], diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index 95b72d4acc8..eb8a3686b61 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { isRetryableNetworkError } from '@/lib/core/errors/retryable-infrastructure' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { type AwsSessionCredentials, @@ -112,16 +113,6 @@ export async function resolveAuthorizedAwsCredential( } const THROTTLING_ERROR_NAMES = new Set(['Throttling', 'ThrottlingException']) -const NETWORK_ERROR_CODES = new Set([ - 'ECONNRESET', - 'ECONNREFUSED', - 'EPIPE', - 'ETIMEDOUT', - 'EHOSTUNREACH', - 'ENETUNREACH', - 'ENOTFOUND', - 'EAI_AGAIN', -]) /** * Whether resolving a credential failed because AWS was briefly unavailable rather @@ -138,7 +129,7 @@ function isTransientAwsFailure(error: unknown): boolean { status === 429 || (typeof status === 'number' && status >= 500) || error.name === 'TimeoutError' || - NETWORK_ERROR_CODES.has(String(error.code)) + isRetryableNetworkError(error) ) } From 171215d183bf214c1fdea7135a1de85fca2705bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:50:35 -0800 Subject: [PATCH 36/83] feat(credentials): connect Linear OAuth apps with client credentials The Linear saved credential gains a client-credentials mode beside the personal API key. Saved with authMethod client_credentials, it stores the app's client ID and secret and mints an app-actor token at api.linear.app/oauth/token with the fixed scope read,write, so two credentials for one app never request different scopes (which Linear treats as replacing the app's tokens). Linear documents that tokens with the same scopes coexist, so each worker caches its own, and a token is reused for at most an hour so one revoked early is replaced quickly. A client-credentials token acts as the app, so connecting falls back to the workspace (organization.id) as the principal when Linear returns no viewer. The connect copy warns that requesting a token replaces an existing authorization-code installation of the app and that rotating the secret invalidates its tokens. Personal API keys and Linear OAuth credentials are unchanged. --- .../integrations/linear-service-account.mdx | 22 +++- .../service-account-fields.test.ts | 7 ++ .../client-credentials.ts | 6 + .../token-service-accounts/descriptors.ts | 8 ++ .../grants/linear.test.ts | 83 ++++++++++++++ .../token-service-accounts/grants/linear.ts | 103 ++++++++++++++++++ .../validators/linear.test.ts | 32 ++++++ .../validators/linear.ts | 17 ++- apps/sim/lib/oauth/credential-service.test.ts | 33 ++++++ 9 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/grants/linear.ts diff --git a/apps/docs/content/docs/integrations/linear-service-account.mdx b/apps/docs/content/docs/integrations/linear-service-account.mdx index f5fba03ad0b..9788ae0be19 100644 --- a/apps/docs/content/docs/integrations/linear-service-account.mdx +++ b/apps/docs/content/docs/integrations/linear-service-account.mdx @@ -1,6 +1,6 @@ --- title: Linear API keys -description: Connect Linear to Sim with a personal API key — ideally created from a dedicated service user for production workflows +description: Connect Linear to Sim with a personal API key from a dedicated service user, or with an OAuth app's client credentials --- import { Callout } from '@/components/ui/callout' @@ -58,6 +58,26 @@ The key must be able to read its own user to pass connection validation. Grant w +## Connect an OAuth app with client credentials instead [#client-credentials] + +A Linear OAuth app with **client credentials tokens** turned on can connect without a personal key. Its token acts as the app rather than a person and, by default, reaches all public teams in the workspace. See Linear's [client credentials documentation](https://linear.app/developers/oauth-2-0-authentication). + + + + In Linear, create or edit the OAuth app under **Settings** → **API** → **OAuth applications** and turn on **Client credentials tokens** + + + In Sim, add the Linear credential, set **Authentication** to **OAuth app (client credentials)**, and paste the app's client ID and client secret + + + Save. Sim requests a `read,write` token from Linear and checks it with a `viewer` and `organization` query before saving. Credentials whose token has no viewer are named after the workspace + + + + +Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim requests new ones itself. + + ## Use the credential in workflows [#using-the-credential-in-workflows] Add a Linear block to your workflow. In the credential dropdown, select the saved Linear API key. Configure the operation and its required fields. diff --git a/apps/sim/lib/credentials/service-account-fields.test.ts b/apps/sim/lib/credentials/service-account-fields.test.ts index d3a8322b769..b463f1fbffa 100644 --- a/apps/sim/lib/credentials/service-account-fields.test.ts +++ b/apps/sim/lib/credentials/service-account-fields.test.ts @@ -66,4 +66,11 @@ describe('required fields for a service-account create', () => { 'domain', ]) }) + + it('asks a Linear client-credentials create for only the client ID and secret', () => { + expect( + getMissingServiceAccountFields('linear-service-account', { authMethod: 'client_credentials' }) + ).toEqual(['clientId', 'clientSecret']) + expect(getMissingServiceAccountFields('linear-service-account', {})).toEqual(['apiToken']) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts index 670a98024ce..f07729cee6f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts +++ b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts @@ -1,8 +1,10 @@ import { isTokenServiceAccountProviderId, + LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, type TokenServiceAccountProviderId, } from '@/lib/credentials/token-service-accounts/descriptors' +import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' /** What a token provider's client-credentials grant needs to mint an access token. */ @@ -45,6 +47,10 @@ const TOKEN_CLIENT_CREDENTIAL_GRANTS: Partial< mint: mintShopifyClientCredentialsToken, sharedToken: true, }, + [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: { + mint: mintLinearClientCredentialsToken, + sharedToken: false, + }, } export function getTokenClientCredentialGrant( diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 7b0205be638..ab4a2f2d463 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -492,6 +492,14 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< }, ], docsUrl: 'https://docs.sim.ai/integrations/linear-service-account', + clientCredentials: { + label: 'OAuth app (client credentials)', + tokenLabel: 'Personal API key (lin_api_)', + helpText: + 'Turn on client credentials tokens for the OAuth app in Linear first. Requesting a token replaces an existing authorization of this app in the workspace, and rotating the client secret invalidates its tokens until you reconnect here.', + invalidCredentialsHelp: + 'Linear rejected these client credentials. Check the client ID and secret, and that client credentials tokens are turned on for the OAuth app.', + }, }, [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: MONDAY_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts new file mode 100644 index 00000000000..53cda3af96d --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts @@ -0,0 +1,83 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' + +const FIELDS = { clientId: 'linear-client', clientSecret: 'linear-secret' } +const mockFetch = vi.fn() + +describe('Linear client credentials grant', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it('requests an app token with the one fixed scope set every credential shares', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://api.linear.app/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'linear-client'], + ['client_secret', 'linear-secret'], + ['scope', 'read,write'], + ]) + return jsonResponse({ + access_token: 'lin_oauth_1', + token_type: 'Bearer', + expires_in: 2_591_999, + scope: ['read', 'write'], + }) + }) + + const minted = await mintLinearClientCredentialsToken(FIELDS) + + expect(minted.accessToken).toBe('lin_oauth_1') + }) + + it('reuses a 30-day token for at most an hour so an early revocation heals', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ access_token: 'lin_oauth_1', expires_in: 2_591_999 }) + ) + + const minted = await mintLinearClientCredentialsToken(FIELDS) + + expect(minted.expiresInSeconds).toBe(3600) + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue( + jsonResponse( + { error: 'unsupported_grant_type', error_description: 'Client does not support it' }, + { status } + ) + ) + + await expect(mintLinearClientCredentialsToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it.each([ + ['no access token', { expires_in: 3600 }], + ['no lifetime', { access_token: 'lin_oauth_1' }], + ])('refuses a token response with %s', async (_label, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect(mintLinearClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('never sends a request without a client secret', async () => { + await expect( + mintLinearClientCredentialsToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts new file mode 100644 index 00000000000..716c16f3c30 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts @@ -0,0 +1,103 @@ +import { isRecordLike } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { + fetchProvider, + providerFailureCode, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token' +const STEP = 'linear_token_mint' +/** + * Every Sim credential requests exactly these scopes: Linear replaces an app's existing + * client-credentials tokens in a workspace when a request asks for different scopes, so two + * credentials for one app must never disagree. They match the scopes Linear's tools use. + */ +const LINEAR_CLIENT_CREDENTIAL_SCOPE = 'read,write' +/** + * Linear tokens last 30 days and cannot be refreshed, but a token revoked early (a secret + * rotation, or another scope request) answers 401 until it is replaced, so a minted token is + * reused for at most an hour before a fresh one is requested. + */ +const MAX_REUSE_SECONDS = 3600 + +/** + * Mints an app-actor token for a Linear OAuth app with client credentials turned on. Tokens + * with the same scopes coexist, so each worker may hold its own. + * + * @see https://linear.app/developers/oauth-2-0-authentication + */ +export async function mintLinearClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + const response = await fetchProvider( + LINEAR_TOKEN_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: fields.clientSecret, + scope: LINEAR_CLIENT_CREDENTIAL_SCOPE, + }).toString(), + redirect: 'error', + cache: 'no-store', + signal: options?.signal, + }, + STEP + ) + if (!response.ok) { + const body = await readProviderErrorSnippet(response) + throw new TokenServiceAccountValidationError( + providerFailureCode(response.status), + response.status, + { step: STEP, body } + ) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: 64 * 1024, + label: 'Linear token response', + signal: options?.signal, + }) + } catch { + options?.signal?.throwIfAborted() + payload = undefined + } + if ( + !isRecordLike(payload) || + typeof payload.access_token !== 'string' || + !payload.access_token.trim() || + typeof payload.expires_in !== 'number' || + !Number.isSafeInteger(payload.expires_in) || + payload.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid token response', + }) + } + return { + accessToken: payload.access_token, + expiresInSeconds: Math.min(payload.expires_in, MAX_REUSE_SECONDS), + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index 23841e28ca0..27797b17f3e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -103,4 +103,36 @@ describe('validateLinearServiceAccount', () => { status: 400, }) }) + + it('identifies a client-credentials app token by its workspace when Linear returns no viewer', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { + data: { viewer: null, organization: { id: 'org-1', name: 'Acme' } }, + errors: [{ message: 'Forbidden', extensions: { code: 'FORBIDDEN' } }], + }, + 200 + ) + ) + + const result = await validateLinearServiceAccount({ + apiToken: 'lin_oauth_minted', + authMethod: 'client_credentials', + }) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect((init.headers as Record).Authorization).toBe('Bearer lin_oauth_minted') + expect(result.principal).toEqual({ kind: 'tenant', id: 'org-1', label: 'Acme' }) + expect(result.storedMetadata).toEqual({ organizationId: 'org-1' }) + }) + + it('still refuses a pasted key that answers with no viewer', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { viewer: null, organization: { id: 'org-1', name: 'Acme' } } }, 200) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 46c98aef7b0..2673939e474 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,4 +1,5 @@ -import { userPrincipal } from '@/lib/credentials/principal' +import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' +import { CLIENT_CREDENTIALS_AUTH_METHOD } from '@/lib/credentials/token-service-accounts/descriptors' import { fetchProvider, parseProviderJson, @@ -81,6 +82,7 @@ export async function validateLinearServiceAccount( 'Content-Type': 'application/json', }, body: JSON.stringify({ query: VIEWER_QUERY }), + redirect: 'error', }, 'viewer' ) @@ -112,6 +114,18 @@ export async function validateLinearServiceAccount( } const payload = await parseProviderJson(res, 'viewer') + const organization = payload.data?.organization + // A client-credentials token acts as the app, not a person: Linear documents no viewer for + // that actor, so the workspace it was minted for is the identity when no viewer comes back. + const appActor = fields.authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + if (appActor && !payload.data?.viewer?.id && organization?.id) { + return { + displayName: organization.name || 'Linear workspace', + principal: tenantPrincipal(organization.id, organization.name), + auditMetadata: { linearOrganizationId: organization.id }, + storedMetadata: { organizationId: organization.id }, + } + } if (hasCredentialError(payload.errors)) { throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { step: 'viewer', @@ -127,7 +141,6 @@ export async function validateLinearServiceAccount( }) } - const organization = payload.data?.organization const storedMetadata: Record = {} const auditMetadata: Record = {} if (organization?.id) { diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index fa68b82c920..6b3752035a5 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -1413,6 +1413,39 @@ describe('token provider credentials saved in client-credentials mode', () => { ).resolves.toMatchObject({ accessToken: 'minted-2' }) }) + it('keeps a Linear app token on the instance and re-mints only when its secret changes', async () => { + const linearBlob = { + type: 'token_service_account', + providerId: 'linear-service-account', + authMethod: 'client_credentials', + clientId: 'linear-client', + clientSecret: 'linear-secret', + } + fetchMock.mockImplementation(async () => + Response.json({ + access_token: `lin_oauth_${fetchMock.mock.calls.length}`, + expires_in: 2_591_999, + }) + ) + storeBlob('linear-cc', linearBlob, 4) + + await expect( + resolveServiceAccountToken('linear-cc', 'linear-service-account') + ).resolves.toEqual({ accessToken: 'lin_oauth_1', domain: undefined }) + await expect( + resolveServiceAccountToken('linear-cc', 'linear-service-account') + ).resolves.toMatchObject({ accessToken: 'lin_oauth_1' }) + expect(fetchMock).toHaveBeenCalledOnce() + + resetDbChainMock() + for (let read = 0; read < 2; read++) { + queueTableRows(credential, [{ encryptedServiceAccountKey: 'enc-linear-cc-rotated' }]) + } + await expect( + resolveServiceAccountToken('linear-cc', 'linear-service-account') + ).resolves.toMatchObject({ accessToken: 'lin_oauth_2' }) + }) + it('still returns a pasted Shopify token as is', async () => { storeBlob( 'shopify-pasted', From 8f3f34dc109f64c1becede1d8ad04d7f9958a031 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:51:12 -0800 Subject: [PATCH 37/83] fix(credentials): warn instead of turning keyless off for a pinned issuer on another path Refusing a pinned WORKLOAD_IDENTITY_ISSUER whose path was not exactly /oidc/workload turned keyless off on upgrade for a deployment that routes a different issuer path to Sim through a proxy, and it also refused pinning the default issuer of a path-prefixed app URL, which the default itself accepts. Trust policies name the issuer, so those operators could only recover by recreating the identity provider and every trust policy. A pinned issuer whose path ends in /oidc/workload is now accepted the same way the default is. Any other path logs a warning that clouds can verify its tokens only if a proxy routes it to /oidc/workload; saving a keyless credential still proves the route end to end. The https, port, query, and fragment refusals are unchanged, and the docs describe the rule. --- .../docs/integrations/aws-credentials.mdx | 2 +- .../self-hosting/environment-variables.mdx | 2 +- .../workload-identity/issuer.test.ts | 29 ++++++++++++++----- .../credentials/workload-identity/issuer.ts | 18 ++++++++---- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index 166ed96ba85..ede81c9503d 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -65,7 +65,7 @@ To rotate a key, create the new key in AWS, reconnect the credential with it, co Keyless roles need two things on a self-hosted deployment: - `WORKLOAD_IDENTITY_SIGNING_KEY`: a PKCS#8 PEM RSA private key that signs Sim's tokens. -- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. A pinned issuer may use another host that reaches Sim, but its path must be `/oidc/workload`, because Sim serves `/.well-known/openid-configuration` and `/jwks` only under that path. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https`, names a port, query, or fragment, or is pinned to another path, because AWS can't use it. +- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. A pinned issuer may use another host that reaches Sim. Its path should end in `/oidc/workload`, because Sim serves `/.well-known/openid-configuration` and `/jwks` under that path; any other path works only if a proxy routes it there, and Sim logs a warning for it. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https` or names a port, query, or fragment, because AWS can't use it. Generate a key with `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`. diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 2c816ddeeaa..11412054792 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -167,7 +167,7 @@ Saved AWS, Databricks, Snowflake, Microsoft app, and Google Cloud credentials ca | Variable | Description | | --- | --- | | `WORKLOAD_IDENTITY_SIGNING_KEY` | PKCS#8 PEM RSA private key that signs Sim's workload-identity tokens: `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048` | -| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. A pinned issuer may use another host that reaches Sim, but its path must be `/oidc/workload`, where Sim serves discovery and keys; any other path keeps keyless off. Never change it once trust policies reference it. | +| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. A pinned issuer may use another host that reaches Sim; its path should end in `/oidc/workload`, where Sim serves discovery and keys, and any other path works only if a proxy routes it there (Sim logs a warning). Never change it once trust policies reference it. | | `WORKLOAD_IDENTITY_PREVIOUS_KEY` | A second published key (private or public PEM) that never signs: the incoming key before a rotation, or the retiring key after it | See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for setup and the rotation steps. diff --git a/apps/sim/lib/credentials/workload-identity/issuer.test.ts b/apps/sim/lib/credentials/workload-identity/issuer.test.ts index 478453ea985..d3ab2054828 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.test.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.test.ts @@ -152,15 +152,30 @@ describe('workload identity issuer', () => { 'https://sim.example.com', 'https://oidc.example.com/workload', 'https://sim.example.com/oidc/workload/v2', - 'https://sim.example.com/OIDC/workload', - ])('stays off for an issuer whose path Sim does not serve discovery on (%s)', async (url) => { - setEnv('WORKLOAD_IDENTITY_ISSUER', url) + ])( + 'keeps a pinned issuer on another path working, warning that a proxy must route it (%s)', + async (url) => { + setEnv('WORKLOAD_IDENTITY_ISSUER', url) + const logger = getMockLogger('WorkloadIdentityIssuer') + logger.warn.mockClear() + const issuer = await loadIssuer() + + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(issuer.getWorkloadIdentityIssuer()).toBe(url) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('/oidc/workload'), { + issuer: url, + }) + } + ) + + it('accepts a pinned issuer behind a path prefix without a warning', async () => { + setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://example.com/sim/oidc/workload') + const logger = getMockLogger('WorkloadIdentityIssuer') + logger.warn.mockClear() const issuer = await loadIssuer() - expect(await issuer.isWorkloadIdentityConfigured()).toBe(false) - expect(getMockLogger('WorkloadIdentityIssuer').error).toHaveBeenCalledWith(expect.any(String), { - error: expect.objectContaining({ message: expect.stringContaining('/oidc/workload') }), - }) + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(logger.warn).not.toHaveBeenCalled() }) it('keeps signing with the current key when the retiring key is malformed', async () => { diff --git a/apps/sim/lib/credentials/workload-identity/issuer.ts b/apps/sim/lib/credentials/workload-identity/issuer.ts index 9103502080c..42be37b1a75 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.ts @@ -73,9 +73,14 @@ async function loadPreviousPublicJwk(): Promise { * AWS and Databricks fetch discovery and keys from the issuer over https on the * default port, and discovery appends paths to it, so any other issuer (or one * with a query or fragment) can never be trusted; keyless stays off - * rather than advertising an issuer no relying party accepts. Sim serves - * discovery and keys only under {@link WORKLOAD_IDENTITY_PATH}, so a pinned - * issuer may name another host that reaches this app but must keep that path. + * rather than advertising an issuer no relying party accepts. + * + * Sim serves discovery and keys under {@link WORKLOAD_IDENTITY_PATH}. A pinned + * issuer whose path ends there reaches them directly or through a + * prefix-stripping proxy, like the default issuer on a path-prefixed app URL. + * Any other path works only if a proxy routes it to that path, which Sim can't + * see, so it is warned about rather than refused: trust policies already name + * the pinned URL, and saving a keyless credential proves the route end to end. */ function assertPublicIssuer(issuer: string): void { const url = new URL(issuer) @@ -85,9 +90,10 @@ function assertPublicIssuer(issuer: string): void { ) } if (env.WORKLOAD_IDENTITY_ISSUER?.trim()) { - if (url.pathname !== WORKLOAD_IDENTITY_PATH) { - throw new Error( - `WORKLOAD_IDENTITY_ISSUER must end in ${WORKLOAD_IDENTITY_PATH}, the path Sim serves discovery and keys on; only its host may differ` + if (!url.pathname.endsWith(WORKLOAD_IDENTITY_PATH)) { + logger.warn( + `WORKLOAD_IDENTITY_ISSUER does not end in ${WORKLOAD_IDENTITY_PATH}, where Sim serves discovery and keys; clouds can verify its tokens only if a proxy routes the issuer there`, + { issuer } ) } return From d2649b99a930426d63987d35adda7a1579abff1e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:52:21 -0800 Subject: [PATCH 38/83] fix(copilot): exempt a hybrid tool's key only when the hosted key applies to the call The Copilot credential check waived a hybrid tool's hosted key param whenever the deployment was hosted, but injectHostedKeyIfNeeded skips the hosted key when the tool's hosting.enabled predicate rejects the params, so such a call could reach the provider with neither a key nor a saved credential. Both now use one hostedKeyMayFill predicate. No hybrid tool declares hosting today, so this is latent. --- apps/sim/tools/index.test.ts | 29 +++++++++++++++++++++++++++++ apps/sim/tools/index.ts | 17 +++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 664864fe46a..1115798922a 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -4017,6 +4017,35 @@ describe('Internal Route Trust', () => { }) ) }) + + it('makes copilot pass the key when the hosted key does not apply to these params', async () => { + const hostedTool = { + ...tokenTool, + id: 'test_saved_token_conditionally_hosted', + hosting: { + enabled: () => false, + envKeyPrefix: 'TEST_HOSTED_KEY', + apiKeyParam: 'apiKey', + pricing: { type: 'per_request' as const, cost: 0.01 }, + rateLimit: { mode: 'per_request' as const, requestsPerMinute: 60 }, + }, + } + ;(tools as Record)[hostedTool.id] = hostedTool + setEnvFlags({ isHosted: true }) + + const result = await executeTool(hostedTool.id, { + baseUrl: 'api.example.com', + _context: { workspaceId: 'workspace-456', userId: 'user-1', copilotToolExecution: true }, + }) + setEnvFlags({ isHosted: false }) + Reflect.deleteProperty(tools, hostedTool.id) + + expect(result).toMatchObject({ + success: false, + error: expect.stringMatching(/credentialId.*apiKey/), + }) + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) }) it.each(['oauth', 'managed_oauth', undefined])( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 8df5f9ddbe5..b23a17cf85c 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -517,6 +517,15 @@ function normalizeCopilotCredentialParams(params: Record): void } } +/** + * Whether this deployment and these params let Sim's hosted key fill + * `tool.hosting.apiKeyParam`, the gate {@link injectHostedKeyIfNeeded} opens with. + */ +function hostedKeyMayFill(tool: ToolDefinition, params: Record): boolean { + if (!isHosted || !tool.hosting) return false + return !tool.hosting.enabled || tool.hosting.enabled(params) +} + function enforceCopilotCredentialSelection( toolId: string, tool: ToolDefinition, @@ -537,7 +546,7 @@ function enforceCopilotCredentialSelection( const missing = (tool.oauth.credentialSuppliedParams ?? []).filter( (paramId) => tool.params[paramId]?.required && - !(isHosted && paramId === tool.hosting?.apiKeyParam) && + !(paramId === tool.hosting?.apiKeyParam && hostedKeyMayFill(tool, params)) && (params[paramId] === undefined || params[paramId] === null || params[paramId] === '') ) if (missing.length > 0) { @@ -655,11 +664,7 @@ async function injectHostedKeyIfNeeded( requestId: string, signal: AbortSignal | undefined ): Promise { - if (!tool.hosting) return { isUsingHostedKey: false } - if (!isHosted) return { isUsingHostedKey: false } - if (tool.hosting.enabled && !tool.hosting.enabled(params)) { - return { isUsingHostedKey: false } - } + if (!tool.hosting || !hostedKeyMayFill(tool, params)) return { isUsingHostedKey: false } const { envKeyPrefix, apiKeyParam, byokProviderId, rateLimit } = tool.hosting // A selected saved credential supplies this key; Sim's key must never stand in for it. From b8515f209f385ebe64c3e3d166b4918cd9504fef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:53:11 -0800 Subject: [PATCH 39/83] fix(databricks): stop repeating 'Try again' when the save-time probe cannot reach the workspace The unreachable-workspace error ended in 'Try again.', and the federation-policy check that wraps it adds its own, so a save showed 'Try again. Try again.'. The low-level message now only names the failure. --- .../databricks-service-account.server.test.ts | 16 ++++++++++++++++ .../databricks-service-account.server.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index dd60ad1426f..420689c5606 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -157,6 +157,22 @@ describe('saving a keyless Databricks connection', () => { ).rejects.toThrow(/could not confirm/) expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) + + it('asks once to try again when the any-subject check cannot reach the workspace', async () => { + principalTrusting(true) + const trusted = fetch.getMockImplementation() + fetch.mockImplementation(async (url: string, init: RequestInit) => { + const body = new URLSearchParams(String(init?.body)) + if (url.endsWith('/oidc/v1/token') && body.get('subject_token') !== `token-for:${SUBJECT}`) { + throw new TypeError('fetch failed') + } + return trusted?.(url, init) + }) + + await expect( + verifyAndEncryptDatabricksCredential(JSON.stringify(config), reconnectOwner) + ).rejects.toThrow(/this credential: Could not reach the Databricks workspace\. Try again\.$/) + }) }) describe('getting a Databricks token for a run', () => { diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index cb5ab40422b..6281a15be41 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -154,7 +154,7 @@ async function requestWorkspaceToken( } catch { signal?.throwIfAborted() // A network failure, timeout, or refused redirect says nothing about the credential. - throw new DatabricksCredentialError('Could not reach the Databricks workspace. Try again.', 502) + throw new DatabricksCredentialError('Could not reach the Databricks workspace', 502) } const payload = (await readResponseJsonWithLimit(response, { maxBytes: MAX_RESPONSE_BYTES, From 03cd8e38d72cc5eb8af4ada67f5967ec0933c573 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:53:18 -0800 Subject: [PATCH 40/83] fix(credentials): refuse redirects by default in fetchProvider Every fetchProvider caller sends a secret, and fetch replays custom headers and 307/308 bodies to whatever host a redirect names. Default the helper to redirect: 'error' so validators and client-credential mints inherit it, and drop the per-call copies this branch had added. --- .../token-service-accounts/errors.ts | 21 ++++++++++++------- .../validators/airtable.ts | 1 - .../validators/asana.ts | 1 - .../validators/attio.ts | 1 - .../validators/calcom.ts | 1 - .../validators/claude-platform.ts | 1 - .../validators/clickup.ts | 1 - .../validators/linear.ts | 1 - .../validators/monday.ts | 1 - .../validators/notion.ts | 1 - .../validators/pipedrive.ts | 1 - .../validators/redirects.test.ts | 11 ++++++++++ .../validators/shopify.ts | 1 - .../validators/trello.ts | 2 +- .../validators/wealthbox.ts | 7 ++----- .../validators/webflow.ts | 1 - 16 files changed, 27 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 6b4d85dd61f..65911ecb808 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -111,13 +111,6 @@ export interface FetchProviderOptions { dnsFailureReason?: string } -/** - * Fetches a provider verification endpoint, mapping network-level failures - * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape - * as raw undici errors — whose `cause` can carry connection details — and are - * never blamed on the pasted token. DNS-resolution failures can optionally be - * mapped to a different code via {@link FetchProviderOptions}. - */ const PROVIDER_FETCH_TIMEOUT_MS = 10_000 /** @@ -130,6 +123,18 @@ export function isHostNotFound(error: unknown): boolean { return failure?.code === 'ENOTFOUND' || failure?.cause?.code === 'ENOTFOUND' } +/** + * Fetches a provider verification endpoint, mapping network-level failures + * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape + * as raw undici errors — whose `cause` can carry connection details — and are + * never blamed on the pasted token. DNS-resolution failures can optionally be + * mapped to a different code via {@link FetchProviderOptions}. + * + * Redirects are refused (`redirect: 'error'`) unless `init` names another mode: + * every caller sends a secret, and fetch replays custom headers and 307/308 + * bodies to whatever host a redirect names. A refused redirect is a network + * failure, so it surfaces as `provider_unavailable`. + */ export async function fetchProvider( url: string, init: RequestInit, @@ -139,7 +144,7 @@ export async function fetchProvider( try { const timeout = AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout - return await fetch(url, { ...init, signal }) + return await fetch(url, { ...init, redirect: init.redirect ?? 'error', signal }) } catch (error) { init.signal?.throwIfAborted() // Only ENOTFOUND proves the host doesn't exist; EAI_AGAIN is a transient diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index 465e93d146e..c70ed3c4b1b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -31,7 +31,6 @@ export async function validateAirtableServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, - redirect: 'error', }, 'whoami' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index 7e5fbe3b352..e35f0adff37 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -36,7 +36,6 @@ export async function validateAsanaServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, - redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index 7470a7983f5..c0b7ccc1d0d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -37,7 +37,6 @@ export async function validateAttioServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, - redirect: 'error', }, 'self' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index 6549da6f905..bd8cc59ab07 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -39,7 +39,6 @@ export async function validateCalcomServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, - redirect: 'error', }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts index 4a13ae1ac94..566834f4fd2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts @@ -29,7 +29,6 @@ export async function validateClaudePlatformServiceAccount( 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': MANAGED_AGENTS_BETA, }, - redirect: 'error', }, 'agents_list' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts index f7371a55b20..6facd5d4b28 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -39,7 +39,6 @@ export async function validateClickupServiceAccount( Authorization: clickupAuthorizationHeader(fields.apiToken), 'Content-Type': 'application/json', }, - redirect: 'error', }, 'user' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 87313015e11..46c98aef7b0 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -81,7 +81,6 @@ export async function validateLinearServiceAccount( 'Content-Type': 'application/json', }, body: JSON.stringify({ query: VIEWER_QUERY }), - redirect: 'error', }, 'viewer' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 9431d5634e4..75fa3fdbe9e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -80,7 +80,6 @@ export async function validateMondayServiceAccount( method: 'POST', headers: mondayHeaders(fields.apiToken), body: JSON.stringify({ query: VALIDATION_QUERY }), - redirect: 'error', }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index a889fa49af8..e2b75762e48 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -43,7 +43,6 @@ export async function validateNotionServiceAccount( 'Notion-Version': NOTION_VERSION, Accept: 'application/json', }, - redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts index 4af7f4204e9..66da8628dfa 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts @@ -42,7 +42,6 @@ export async function validatePipedriveServiceAccount( 'x-api-token': fields.apiToken, Accept: 'application/json', }, - redirect: 'error', }, 'users_me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts index 5e97261964a..01e8ccb3fcb 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts @@ -1,5 +1,6 @@ import { jsonResponse } from '@sim/testing/helpers/http' import { describe, expect, it, vi } from 'vitest' +import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import type { TokenServiceAccountFields, TokenServiceAccountValidationResult, @@ -43,3 +44,13 @@ describe('saved key checks that send the key in a custom header', () => { await expect(validate(fields)).rejects.toMatchObject({ code: 'provider_unavailable' }) }) }) + +describe('client-credential mints that send the secret in the request body', () => { + it('Box refuses to replay its client secret to a redirect target', async () => { + vi.stubGlobal('fetch', redirectingFetch({ access_token: 'elsewhere', expires_in: 3600 })) + + await expect( + mintBoxServiceAccountToken({ clientId: 'box-cid', clientSecret: 'box-secret', orgId: '1' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index a19f6735f9f..54ff330897a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -95,7 +95,6 @@ export async function validateShopifyServiceAccount( 'X-Shopify-Access-Token': fields.apiToken, }, body: JSON.stringify({ query: SHOP_QUERY }), - redirect: 'error', }, 'shop_query', { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 8caa24aaf32..500a8c6b537 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -48,7 +48,7 @@ export async function validateTrelloServiceAccount( const res = await fetchProvider( url.toString(), - { headers: { Accept: 'application/json' }, redirect: 'error' }, + { headers: { Accept: 'application/json' } }, 'members_me' ) if (res.status === 401) { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index 320bcae27d3..ac97ca76474 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -33,7 +33,7 @@ async function probeAccessTokenHeader(apiToken: string): Promise { try { const res = await fetchProvider( ME_URL, - { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' }, redirect: 'error' }, + { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' } }, 'me-access-token-probe' ) return res.ok @@ -61,10 +61,7 @@ export async function validateWealthboxServiceAccount( const res = await fetchProvider( ME_URL, - { - headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, - redirect: 'error', - }, + { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' } }, 'me' ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 1a8b5623834..e1b1296eb3e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -37,7 +37,6 @@ export async function validateWebflowServiceAccount( Authorization: `Bearer ${fields.apiToken}`, Accept: 'application/json', }, - redirect: 'error', }, 'list_sites' ) From b77de296ab316404c88158bed27db08f78454079 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:53:19 -0800 Subject: [PATCH 41/83] fix(credentials): settle a HubSpot key account-info refuses with a contacts read HubSpot documents service keys only against its CRM REST routes, not the token-info or account-info routes. When account-info answers 401, confirm on a one-contact read, where a 200 or JSON 403 proves the key is live and a 401 rejects it. --- .../validators/hubspot.test.ts | 72 +++++++++++++++++-- .../validators/hubspot.ts | 46 ++++++++---- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 97b063afdf5..85d9242d575 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -4,6 +4,7 @@ import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-a const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info' const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details' +const CONTACTS_URL = 'https://api.hubapi.com/crm/v3/objects/contacts?limit=1' const FIELDS = { apiToken: 'pat-na1-aaaa-bbbb' } @@ -61,12 +62,15 @@ describe('validateHubspotServiceAccount', () => { expectFallbackCall() }) - it('throws invalid_credentials when primary 404 and fallback returns 401', async () => { + it('throws invalid_credentials when primary 404 and both fallbacks return 401', async () => { mockFetch .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) .mockResolvedValueOnce( jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', @@ -128,19 +132,77 @@ describe('validateHubspotServiceAccount', () => { expectFallbackCall() }) - it('throws invalid_credentials when both routes answer 401', async () => { + it('throws invalid_credentials when every route answers 401', async () => { + const unauthorized = () => + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + mockFetch + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(unauthorized()) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + const [url, init] = mockFetch.mock.calls[2] + expect(url).toBe(CONTACTS_URL) + expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb') + }) + + it.each([ + ['reads contacts', () => jsonResponse({ results: [] })], + [ + 'lacks the contacts scope', + () => jsonResponse({ status: 'error', category: 'MISSING_SCOPES' }, 403), + ], + ])( + 'accepts a service key that account-info refuses but that %s', + async (_case, contactsAnswer) => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(contactsAnswer()) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toEqual({ + displayName: 'HubSpot', + principal: null, + auditMetadata: {}, + }) + expect(mockFetch.mock.calls[2][0]).toBe(CONTACTS_URL) + } + ) + + it('does not take a firewall 403 page from the contacts read as a live key', async () => { mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) .mockResolvedValueOnce( jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) ) .mockResolvedValueOnce( - jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + new Response('Access denied', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) ) await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ - name: 'TokenServiceAccountValidationError', code: 'invalid_credentials', - status: 401, + }) + }) + + it('reports an outage when the contacts read is unavailable', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error' }, 503)) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 84ca61b4a89..6fef19627d9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -13,6 +13,7 @@ import type { const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info' const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details' +const CONTACTS_READ_URL = 'https://api.hubapi.com/crm/v3/objects/contacts?limit=1' interface HubspotTokenInfo { hubId?: number @@ -25,24 +26,46 @@ interface HubspotAccountInfo { portalId?: number } +/** + * Last word on a key account-info refused: a one-contact read, the request + * HubSpot's service-key guide shows a key making. HubSpot answers a credential + * it does not accept with a 401 and one that lacks the object's scope with a + * 403, so a 200 or a JSON 403 proves the key is live. The route reports no + * portal, so the key is accepted without a principal. + */ +async function verifyViaContactsRead( + accessToken: string +): Promise { + const res = await fetchProvider( + CONTACTS_READ_URL, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + 'contacts_read' + ) + if (!isVendorJsonForbidden(res)) await throwForProviderResponse(res, 'contacts_read') + await res.body?.cancel().catch(() => undefined) + return { displayName: 'HubSpot', principal: null, auditMetadata: {} } +} + /** * Fallback verification via the Account Information API, a regular REST route - * that any HubSpot Bearer credential (service key or private app token) can - * call. It answers a bad token with a JSON 401. A 200 proves the token is - * live; a JSON 403 means the token authenticated but lacks account-info - * access, still a live token. An HTML 403 is a firewall page, not HubSpot. + * that reports the portal. A 200 proves the token is live; a JSON 403 means the + * token authenticated but lacks account-info access, still a live token. An + * HTML 403 is a firewall page, not HubSpot. HubSpot does not document how this + * route answers a service key, so its 401 is settled by + * {@link verifyViaContactsRead} rather than taken as a rejection. */ async function verifyViaAccountInfo( accessToken: string ): Promise { const res = await fetchProvider( ACCOUNT_INFO_URL, - { - headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, - redirect: 'error', - }, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, 'account_info' ) + if (res.status === 401) { + await res.body?.cancel().catch(() => undefined) + return verifyViaContactsRead(accessToken) + } if (isVendorJsonForbidden(res)) { // The token is live but cannot read account info, so neither the portal // nor the creating user is knowable on this path. @@ -80,9 +103,9 @@ const HUBSPOT_KEY_PREFIX = /^pat-/ * That endpoint is documented only for private app tokens, and live probing * shows it returns a bare 404 (HTML) for a token it does not recognize. So a * 400, 403 or 404, a 401 for a `pat-` key (which may be a service key), or a - * 200 without a portal falls back to the Account Information API, which - * distinguishes a rejected token (JSON 401) from a live one (200/403). Any - * other token it answers with 401 is rejected there. + * 200 without a portal falls back to the Account Information API, and a key + * that route refuses is settled by a contacts read, the route HubSpot documents + * for service keys. A token-info 401 for any other token is a rejection. * * The display name is `HubSpot portal {hubId}` when the portal is known — the * account-info `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), @@ -102,7 +125,6 @@ export async function validateHubspotServiceAccount( Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ tokenKey: accessToken }), - redirect: 'error', }, 'access_token_info' ) From 43f9264e4fab5594181d042e94bd249db551bb7a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:53:44 -0800 Subject: [PATCH 42/83] test(security): move the typed base URL normalizer test next to input-validation normalizeHttpBaseUrl and refuseUnsafeTypedUrl moved from the token validator module into input-validation; their test now lives with them. --- .../lib/core/security/input-validation.test.ts | 17 +++++++++++++++++ .../validators/http.test.ts | 14 -------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index aa7967b7a0f..abfc240d6c9 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,6 +1,8 @@ import { defaultMockEnv, envFlagsMock, resetEnvFlagsMock, resetEnvMock, setEnv } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' import { + normalizeHttpBaseUrl, + refuseUnsafeTypedUrl, validateAirtableId, validateCallbackUrl, validateEnum, @@ -663,3 +665,18 @@ describe('validateSupabaseProjectId', () => { expect(validateSupabaseProjectId(value).isValid).toBe(false) }) }) + +describe('typed base URLs', () => { + it('refuses a mistyped scheme instead of reading the scheme as a host', () => { + expect(() => normalizeHttpBaseUrl('https:/grafana.example.com')).toThrow(/scheme/) + expect(() => refuseUnsafeTypedUrl('https:/evil.com')).toThrow(/scheme/) + expect(() => refuseUnsafeTypedUrl('HTTP:evil.com')).toThrow(/scheme/) + // The URL parser drops tabs and newlines, so they must not hide a scheme from the guard. + expect(() => refuseUnsafeTypedUrl('ht\ntps:/evil.com')).toThrow(/scheme/) + expect(() => normalizeHttpBaseUrl('ht\ttps:/evil.com')).toThrow(/scheme/) + expect(refuseUnsafeTypedUrl('https://grafana.example.com')).toBe('https://grafana.example.com') + expect(normalizeHttpBaseUrl('grafana.example.com:3000/')).toBe( + 'https://grafana.example.com:3000' + ) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts index f5568efac4d..cfc9e4a3c13 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts @@ -8,7 +8,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { EgressRefusedError } from '@/lib/core/network/transport.server' -import { normalizeHttpBaseUrl, refuseUnsafeTypedUrl } from '@/lib/core/security/input-validation' import { tokenCheckValidator } from '@/lib/credentials/token-service-accounts/validators/http' const validate = tokenCheckValidator({ @@ -172,19 +171,6 @@ describe('tokenCheckValidator with a typed host', () => { ) }) - it('refuses a mistyped scheme instead of reading the scheme as a host', () => { - expect(() => normalizeHttpBaseUrl('https:/grafana.example.com')).toThrow(/scheme/) - expect(() => refuseUnsafeTypedUrl('https:/evil.com')).toThrow(/scheme/) - expect(() => refuseUnsafeTypedUrl('HTTP:evil.com')).toThrow(/scheme/) - // The URL parser drops tabs and newlines, so they must not hide a scheme from the guard. - expect(() => refuseUnsafeTypedUrl('ht\ntps:/evil.com')).toThrow(/scheme/) - expect(() => normalizeHttpBaseUrl('ht\ttps:/evil.com')).toThrow(/scheme/) - expect(refuseUnsafeTypedUrl('https://grafana.example.com')).toBe('https://grafana.example.com') - expect(normalizeHttpBaseUrl('grafana.example.com:3000/')).toBe( - 'https://grafana.example.com:3000' - ) - }) - it('refuses a host its normalizer rejects without sending the token anywhere', async () => { await expect( validateHost({ apiToken: 'good', domain: 'evil.example.com@tools.example.com' }) From ed5ae0e510290e069ff36392d5382f7cd9cf3d10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:56:28 -0800 Subject: [PATCH 43/83] feat(credentials): saved SailPoint API client credential and credential-only block A SailPoint credential holds a tenant plus an API client or personal access token client id and secret. The minter shares the legacy block's token exchange (now requestSailPointToken in lib/internal/sailpoint/client: the client_credentials grant at the tenant's /oauth/token, redirects refused, bounded 429 retries, and a SailPointAuthError carrying the status), so 4xx maps to invalid_credentials and 5xx, throttling, or network failures to provider_unavailable. The token lifetime comes from expires_in. - The tenant is normalized to {tenant}.api.identitynow.com, the host SailPoint documents for API and token calls, from a tenant name, the API host, or the tenant's UI URL. Anything else (other domains, credentials, ports, more than one tenant label) is refused before the secret is sent. The legacy block's inline tenants, including identitynowgov.com hosts, are unchanged. - The credential fills the hidden accessToken and the tenant (its API host); the operation sends that token as is and never re-mints it on a 401. Inline credentials keep their cached token and single 401 refresh. - sailpoint_v2 is the credential-only successor; the legacy block keeps its inline tenant, client id, and secret and is hidden from the toolbar. --- .../content/docs/integrations/sailpoint.mdx | 4 +- apps/sim/blocks/blocks/sailpoint.ts | 17 +++ apps/sim/blocks/registry-maps.ts | 3 +- .../client-credential-accounts/descriptors.ts | 69 +++++++++++ .../minters/sailpoint.test.ts | 113 ++++++++++++++++++ .../minters/sailpoint.ts | 84 +++++++++++++ .../client-credential-accounts/server.ts | 10 +- apps/sim/lib/internal/sailpoint/client.ts | 84 ++++++++++--- .../internal/sailpoint/execute-tool.test.ts | 41 +++++++ apps/sim/lib/internal/sailpoint/operations.ts | 18 +-- apps/sim/lib/internal/sailpoint/schema.ts | 26 +++- apps/sim/lib/oauth/oauth.ts | 18 +++ apps/sim/lib/oauth/token-resolution.test.ts | 1 + apps/sim/lib/oauth/types.ts | 2 + apps/sim/serializer/saved-credentials.test.ts | 6 + apps/sim/tools/sailpoint/common.ts | 27 +++-- apps/sim/tools/sailpoint/definitions.ts | 2 + apps/sim/tools/sailpoint/types.ts | 9 +- apps/sim/tools/shared/saved-credentials.ts | 11 ++ 19 files changed, 502 insertions(+), 43 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts diff --git a/apps/docs/content/docs/integrations/sailpoint.mdx b/apps/docs/content/docs/integrations/sailpoint.mdx index fdafe235639..9d8784bcca8 100644 --- a/apps/docs/content/docs/integrations/sailpoint.mdx +++ b/apps/docs/content/docs/integrations/sailpoint.mdx @@ -10,7 +10,9 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -The SailPoint integration connects Sim workflows to SailPoint Identity Security Cloud (ISC) with a Personal Access Token (PAT). Enter the tenant name from your ISC URL—or the full `*.api.identitynow.com` / `*.api.identitynowgov.com` host—plus the PAT client ID and client secret. Sim exchanges those credentials at the tenant's `/oauth/token` endpoint and calls SailPoint's current service-versioned endpoints, such as `/identities/v1`, `/access-requests/v1`, and `/certifications/v1`. There is no global API-version setting. +The SailPoint integration connects Sim workflows to SailPoint Identity Security Cloud (ISC) with a Personal Access Token (PAT) or an API client that has the client credentials grant. Connect it once as a SailPoint credential: enter the tenant name from your ISC URL (or its `*.api.identitynow.com` host) plus the client ID and client secret. Sim checks the credential by exchanging it at the tenant's `/oauth/token` endpoint when you save it, then calls SailPoint's current service-versioned endpoints, such as `/identities/v1`, `/access-requests/v1`, and `/certifications/v1`. There is no global API-version setting. + +Workflows built with the earlier SailPoint block keep the tenant, client ID, and client secret they entered in the block, including `*.api.identitynowgov.com` hosts. A saved credential accepts only `*.api.identitynow.com` tenants, the domain SailPoint documents for API calls. ## Permissions diff --git a/apps/sim/blocks/blocks/sailpoint.ts b/apps/sim/blocks/blocks/sailpoint.ts index 69309d04f29..f83275f5db7 100644 --- a/apps/sim/blocks/blocks/sailpoint.ts +++ b/apps/sim/blocks/blocks/sailpoint.ts @@ -5,6 +5,7 @@ import { normalizeFileInput, parseOptionalJsonInput, parseOptionalNumberInput, + withSavedCredential, } from '@/blocks/utils' const SAILPOINT_OPERATIONS = [ @@ -134,6 +135,8 @@ const ACCEPTED_OUTPUT_OPERATIONS = [ export const SailPointBlock: BlockConfig = { type: 'sailpoint', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'sailpoint_v2' }, name: 'SailPoint', description: 'Govern identities and access in SailPoint Identity Security Cloud', longDescription: @@ -1891,3 +1894,17 @@ export const SailPointBlockMeta = { }, ], } as const satisfies BlockMeta + +export const SailPointV2Block: BlockConfig = { + ...SailPointBlock, + type: 'sailpoint_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(SailPointBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'sailpoint', + title: 'SailPoint Credential', + }), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 4aa12e3a9ea..1a92d4d4090 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -365,7 +365,7 @@ import { RootlyBlock, RootlyBlockMeta, RootlyV2Block } from '@/blocks/blocks/roo import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' import { RssBlock, RssBlockMeta } from '@/blocks/blocks/rss' import { S3Block, S3BlockMeta, S3V2Block } from '@/blocks/blocks/s3' -import { SailPointBlock, SailPointBlockMeta } from '@/blocks/blocks/sailpoint' +import { SailPointBlock, SailPointBlockMeta, SailPointV2Block } from '@/blocks/blocks/sailpoint' import { SalesforceBlock, SalesforceBlockMeta } from '@/blocks/blocks/salesforce' import { SapConcurBlock, SapConcurBlockMeta } from '@/blocks/blocks/sap_concur' import { SapS4HanaBlock, SapS4HanaBlockMeta } from '@/blocks/blocks/sap_s4hana' @@ -849,6 +849,7 @@ export const BLOCK_REGISTRY: Record = { s3: S3Block, s3_v2: S3V2Block, sailpoint: SailPointBlock, + sailpoint_v2: SailPointV2Block, salesforce: SalesforceBlock, sap_concur: SapConcurBlock, sap_s4hana: SapS4HanaBlock, diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 984a4385406..fd3924bfb1e 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -141,6 +141,7 @@ export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const export const CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID = 'crowdstrike-service-account' as const export const DATADOG_SERVICE_ACCOUNT_PROVIDER_ID = 'datadog-service-account' as const +export const SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID = 'sailpoint-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -169,6 +170,7 @@ export type ClientCredentialAccountProviderId = | typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID | typeof CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID | typeof DATADOG_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -404,6 +406,40 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): { return { id, ...ZOHO_DESK_DATA_CENTERS[id] } } +/** A SailPoint Identity Security Cloud API host: one tenant label under `api.identitynow.com`. */ +export const SAILPOINT_API_HOST_REGEX = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow\.com$/ + +const SAILPOINT_TENANT_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const SAILPOINT_UI_HOST_REGEX = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.identitynow\.com$/ + +/** + * Normalizes a SailPoint tenant name, API host, UI host, or https URL of either + * to the tenant's API host (`{tenant}.api.identitynow.com`), the domain + * SailPoint documents for API calls and token requests. Returns `undefined` + * for anything else (other domains, credentials, ports, more than one tenant + * label) rather than throwing, so the connect modal's format hint and the + * minter share one admission rule. + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export function normalizeSailPointTenantHost(rawTenant: string): string | undefined { + const trimmed = rawTenant.trim().toLowerCase() + if (!trimmed) return undefined + if (SAILPOINT_TENANT_LABEL_REGEX.test(trimmed)) return `${trimmed}.api.identitynow.com` + let parsed: URL + try { + parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`) + } catch { + return undefined + } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) { + return undefined + } + if (SAILPOINT_API_HOST_REGEX.test(parsed.hostname)) return parsed.hostname + const uiHost = SAILPOINT_UI_HOST_REGEX.exec(parsed.hostname) + return uiHost ? `${uiHost[1]}.api.identitynow.com` : undefined +} + /** * DocuSign's two account systems. Each environment has its own authentication * service and its own eSignature REST hosts, and a token from one is never @@ -516,6 +552,39 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', }, + [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'SailPoint', + connectNoun: 'API client', + fields: [ + { + id: 'orgId', + label: 'Tenant', + placeholder: 'acme or acme.api.identitynow.com', + secret: false, + hintPattern: SAILPOINT_API_HOST_REGEX, + hintNormalize: (value) => normalizeSailPointTenantHost(value) ?? value.trim().toLowerCase(), + hintMessage: + 'Expected your tenant name or its API host, like acme or acme.api.identitynow.com.', + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Use an API client with the client credentials grant, or a personal access token.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/sailpoint', + helpText: + 'The tenant name is shown under Admin > Dashboard > Overview. For a vanity domain, use the tenant name.', + }, [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: DATADOG_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Datadog', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts new file mode 100644 index 00000000000..9bae7eb3828 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts @@ -0,0 +1,113 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintSailPointServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/sailpoint' + +const FIELDS = { clientId: 'sp-client', clientSecret: 'sp-secret', orgId: 'acme' } + +const mockFetch = vi.fn() + +describe('SailPoint API client token exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("posts the client credentials to the tenant's documented token endpoint and never follows a redirect", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + requests.push(new Request(input, init)) + return jsonResponse({ access_token: 'sp-access', expires_in: 749 }) + }) + + const result = await mintSailPointServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://acme.api.identitynow.com/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(Object.fromEntries(new URLSearchParams(await request.text()))).toEqual({ + grant_type: 'client_credentials', + client_id: 'sp-client', + client_secret: 'sp-secret', + }) + expect(result).toMatchObject({ + accessToken: 'sp-access', + expiresInSeconds: 749, + domain: 'acme.api.identitynow.com', + }) + expect(JSON.stringify(result.identity)).not.toContain('sp-secret') + expect(JSON.stringify(result.identity)).not.toContain('sp-access') + }) + + it.each([ + 'acme.api.identitynow.com', + 'https://acme.api.identitynow.com/', + 'https://acme.identitynow.com/ui/d/dashboard', + ' ACME ', + ])('mints for the tenant typed as %j at its API host', async (orgId) => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'sp-access', expires_in: 749 })) + + const result = await mintSailPointServiceAccountToken({ ...FIELDS, orgId }) + + expect(mockFetch.mock.calls[0]?.[0]).toBe('https://acme.api.identitynow.com/oauth/token') + expect(result.domain).toBe('acme.api.identitynow.com') + }) + + it.each([ + '', + 'evil.example', + 'acme.api.identitynow.com.evil.example', + 'acme.api.identitynow.com@evil.example', + 'https://user:pass@acme.api.identitynow.com', + 'https://acme.api.identitynow.com:8443', + 'http://acme.api.identitynow.com', + 'a.b.api.identitynow.com', + 'acme.api.identitynowgov.com', + ])('refuses the tenant %j before sending the secret anywhere', async (orgId) => { + await expect(mintSailPointServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [500, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue( + jsonResponse({ error: 'invalid_client', error_description: 'Bad credentials' }, { status }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('treats a network failure or refused redirect as a provider failure', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValue(jsonResponse({ expires_in: 749 })) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it("keeps a caller's cancellation as a cancellation", async () => { + const controller = new AbortController() + const reason = new DOMException('Workflow cancelled', 'AbortError') + controller.abort(reason) + + await expect( + mintSailPointServiceAccountToken(FIELDS, { signal: controller.signal }) + ).rejects.toBe(reason) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts new file mode 100644 index 00000000000..6ca648de741 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts @@ -0,0 +1,84 @@ +import { normalizeSailPointTenantHost } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + providerFailureCode, + providerFailureReason, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { requestSailPointToken, SailPointAuthError } from '@/lib/internal/sailpoint/client' + +const STEP = 'sailpoint_token_mint' +/** Bounds the whole exchange, including its 429 backoff retries. */ +const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000 + +/** + * Mints a SailPoint Identity Security Cloud access token for an API client or + * personal access token through the client-credentials grant at the tenant's + * documented token endpoint, `https://{tenant}.api.identitynow.com/oauth/token`. + * The tenant comes only from {@link normalizeSailPointTenantHost}, and the + * exchange (shared with the legacy block's inline credentials) refuses + * redirects, so the secret reaches no other host. The token's lifetime comes + * from `expires_in`; the credential cache re-mints before it lapses. + * + * The token response names no identity, so the connect-time check is the mint. + * + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export async function mintSailPointServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'SailPoint') + const host = normalizeSailPointTenantHost(fields.orgId) + if (!host) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'tenant is not a SailPoint Identity Security Cloud API host', + }) + } + + const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS) + let minted: { token: string; expiresInSeconds: number } + try { + minted = await requestSailPointToken( + `https://${host}/oauth/token`, + fields.clientId, + clientSecret, + options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout + ) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof SailPointAuthError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.status), + error.status, + { + step: STEP, + tenant: host, + } + ) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: providerFailureReason(error), + }) + } + + return { + accessToken: minted.token, + expiresInSeconds: minted.expiresInSeconds, + domain: host, + identity: { + displayName: `SailPoint ${host.slice(0, -'.api.identitynow.com'.length)}`, + principal: null, + auditMetadata: { sailpointTenant: host, sailpointClientId: fields.clientId }, + storedMetadata: { tenant: host }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 77c07a8a7db..f6361a15e19 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -16,6 +16,7 @@ import { ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, RAMP_SERVICE_ACCOUNT_PROVIDER_ID, + SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, VANTA_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, @@ -33,6 +34,7 @@ import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-creden import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm' import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' import { mintRampServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/ramp' +import { mintSailPointServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/sailpoint' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintVantaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/vanta' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' @@ -52,7 +54,8 @@ export interface ClientCredentialAccountFields { /** * Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, * Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk - * origin, an Oracle EPM environment URL, or a CrowdStrike member CID). + * origin, an Oracle EPM environment URL, a CrowdStrike member CID, or a + * SailPoint tenant). */ orgId: string /** @@ -119,8 +122,8 @@ export interface ClientCredentialAccountMintResult { */ apiDomain?: string /** - * The region, site, or tenant the token is bound to (a CrowdStrike cloud or - * Datadog site), forwarded to tools as the credential's `domain`, which fills + * The region, site, or tenant the token is bound to (a CrowdStrike cloud, + * Datadog site, or SailPoint tenant API host), forwarded to tools as the credential's `domain`, which fills * the tool param that picks the provider host. */ domain?: string @@ -178,6 +181,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: mintDocuSignServiceAccountToken, [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: mintCrowdStrikeServiceAccountToken, [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: mintDatadogServiceAccountToken, + [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: mintSailPointServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/internal/sailpoint/client.ts b/apps/sim/lib/internal/sailpoint/client.ts index 95160617558..65f7a008eb4 100644 --- a/apps/sim/lib/internal/sailpoint/client.ts +++ b/apps/sim/lib/internal/sailpoint/client.ts @@ -10,12 +10,32 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' +/** API client credentials typed inline on the legacy block. */ export interface SailPointCredentials { clientId: string clientSecret: string tenant: string } +/** A token minted from a saved SailPoint credential, with the tenant host it was minted for. */ +export interface SailPointTokenCredentials { + accessToken: string + tenant: string +} + +export type SailPointAuth = SailPointCredentials | SailPointTokenCredentials + +/** Raised when the tenant's token endpoint refuses the exchange, carrying its HTTP status. */ +export class SailPointAuthError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'SailPointAuthError' + } +} + export interface SailPointHosts { apiBaseUrl: string host: string @@ -157,11 +177,20 @@ async function readBoundedBody( } } -async function exchangeAccessToken( - credentials: SailPointCredentials, +/** + * Exchanges API client credentials for an access token at a tenant's token + * endpoint (`client_credentials` grant, credentials in the form body), with + * redirects refused and up to four backoff retries on 429. A refusal throws + * {@link SailPointAuthError} with the provider's status and message. + * + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export async function requestSailPointToken( + tokenUrl: string, + clientId: string, + clientSecret: string, signal: AbortSignal -): Promise<{ token: string; ttlMs: number }> { - const { tokenUrl } = resolveSailPointHosts(credentials.tenant) +): Promise<{ token: string; expiresInSeconds: number }> { let attempt = 0 while (true) { @@ -174,8 +203,8 @@ async function exchangeAccessToken( }, body: new URLSearchParams({ grant_type: 'client_credentials', - client_id: credentials.clientId, - client_secret: credentials.clientSecret, + client_id: clientId, + client_secret: clientSecret, }).toString(), cache: 'no-store', redirect: 'error', @@ -197,23 +226,39 @@ async function exchangeAccessToken( signal ) if (!response.ok) { - throw new Error(getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint')) + throw new SailPointAuthError( + getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint'), + response.status + ) } if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token) { throw new Error('SailPoint authentication did not return an access token') } const parsedExpiry = Number(data.expires_in) - const expiresInSeconds = Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600 - const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, expiresInSeconds * 100) - // lru-cache reads a TTL of 0 as "never expires", so keep at least 1ms. return { token: data.access_token, - ttlMs: Math.max(Math.floor(expiresInSeconds * 1000 - bufferMs), 1), + expiresInSeconds: Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600, } } } +async function exchangeAccessToken( + credentials: SailPointCredentials, + signal: AbortSignal +): Promise<{ token: string; ttlMs: number }> { + const { tokenUrl } = resolveSailPointHosts(credentials.tenant) + const { token, expiresInSeconds } = await requestSailPointToken( + tokenUrl, + credentials.clientId, + credentials.clientSecret, + signal + ) + const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, expiresInSeconds * 100) + // lru-cache reads a TTL of 0 as "never expires", so keep at least 1ms. + return { token, ttlMs: Math.max(Math.floor(expiresInSeconds * 1000 - bufferMs), 1) } +} + export function invalidateSailPointToken(credentials: SailPointCredentials): void { tokenCache.delete(credentialsCacheKey(credentials)) } @@ -232,8 +277,13 @@ export async function getSailPointAccessToken( return token } +/** + * Sends one authenticated request to the tenant. A saved credential's token + * (`accessToken`) is sent as is; inline credentials use a cached token and, + * when the tenant rejects it, mint once more and retry. + */ export async function sailpointFetch( - credentials: SailPointCredentials, + credentials: SailPointAuth, buildRequest: (hosts: SailPointHosts) => { init: RequestInit; url: string }, options: { maxRetries?: number; signal?: AbortSignal } = {} ): Promise { @@ -244,7 +294,11 @@ export async function sailpointFetch( while (true) { options.signal?.throwIfAborted() - const token = await getSailPointAccessToken(credentials, options.signal) + const inline = 'accessToken' in credentials ? undefined : credentials + const token = + 'accessToken' in credentials + ? credentials.accessToken + : await getSailPointAccessToken(credentials, options.signal) const { init, url } = buildRequest(hosts) const headers = new Headers(init.headers) headers.set('Authorization', `Bearer ${token}`) @@ -258,9 +312,9 @@ export async function sailpointFetch( signal: options.signal, }) - if (response.status === 401 && !refreshedOn401) { + if (response.status === 401 && inline && !refreshedOn401) { await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES) - invalidateSailPointToken(credentials) + invalidateSailPointToken(inline) refreshedOn401 = true continue } diff --git a/apps/sim/lib/internal/sailpoint/execute-tool.test.ts b/apps/sim/lib/internal/sailpoint/execute-tool.test.ts index 229091f672e..ec46bfc3903 100644 --- a/apps/sim/lib/internal/sailpoint/execute-tool.test.ts +++ b/apps/sim/lib/internal/sailpoint/execute-tool.test.ts @@ -121,4 +121,45 @@ describe('SailPoint internal tool handler', () => { expect(mockProcessFilesToUserFiles).not.toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() }) + + it("sends a saved credential's token to its tenant without an exchange, and never re-mints on a 401", async () => { + mockFetch + .mockResolvedValueOnce(Response.json({ id: 'identity' })) + .mockResolvedValueOnce(Response.json({ error: 'expired token' }, { status: 401 })) + const tokenInput = { accessToken: 'minted-token', tenant: 'acme.api.identitynow.com' } + const call = () => + executeSailPointTool({ + toolId: 'sailpoint_get_identity', + input: { ...tokenInput, operation: 'sailpoint_get_identity', id: 'identity' }, + headers: new Headers(), + context: { workflowId: 'workflow' }, + requestId: 'request-id', + }) + + expect((await call()).status).toBe(200) + expect((await call()).status).toBe(401) + + expect(mockFetch).toHaveBeenCalledTimes(2) + for (const [url, init] of mockFetch.mock.calls) { + expect(new URL(String(url)).host).toBe('acme.api.identitynow.com') + expect(new URL(String(url)).pathname).not.toBe('/oauth/token') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer minted-token') + } + }) + + it('refuses input carrying neither inline credentials nor a token before any call', async () => { + const response = await executeSailPointTool({ + toolId: 'sailpoint_get_identity', + input: { tenant: 'acme', operation: 'sailpoint_get_identity', id: 'identity' }, + headers: new Headers(), + context: { workflowId: 'workflow' }, + requestId: 'request-id', + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('Client ID is required'), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/internal/sailpoint/operations.ts b/apps/sim/lib/internal/sailpoint/operations.ts index 21b8b677201..3ed6105282b 100644 --- a/apps/sim/lib/internal/sailpoint/operations.ts +++ b/apps/sim/lib/internal/sailpoint/operations.ts @@ -5,7 +5,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getSailPointErrorMessage, readTotalCount, - type SailPointCredentials, + type SailPointAuth, type SailPointFetchResult, type SailPointHosts, sailpointFetch, @@ -239,7 +239,7 @@ function outputForResult(result: SailPointFetchResult, kind: ResultKind): Respon } async function executeRequest( - credentials: SailPointCredentials, + credentials: SailPointAuth, context: SailPointOperationContext, buildRequest: (hosts: SailPointHosts) => { init: RequestInit; url: string }, kind: ResultKind @@ -250,7 +250,7 @@ async function executeRequest( async function executeLoad( input: InputRecord, - credentials: SailPointCredentials, + credentials: SailPointAuth, context: SailPointOperationContext ): Promise { context.signal?.throwIfAborted() @@ -341,11 +341,13 @@ export async function executeSailPointOperation( context: SailPointOperationContext ): Promise { const input = parsedInput as InputRecord - const credentials: SailPointCredentials = { - clientId: String(input.clientId), - clientSecret: String(input.clientSecret), - tenant: String(input.tenant), - } + const credentials: SailPointAuth = input.accessToken + ? { accessToken: String(input.accessToken), tenant: String(input.tenant) } + : { + clientId: String(input.clientId), + clientSecret: String(input.clientSecret), + tenant: String(input.tenant), + } switch (input.operation) { case 'sailpoint_search': { diff --git a/apps/sim/lib/internal/sailpoint/schema.ts b/apps/sim/lib/internal/sailpoint/schema.ts index abde70b0b8b..bb3b7b39c76 100644 --- a/apps/sim/lib/internal/sailpoint/schema.ts +++ b/apps/sim/lib/internal/sailpoint/schema.ts @@ -24,12 +24,30 @@ const requiredString = (label: string, max = MAX_ID_LENGTH) => const optionalString = (max = MAX_FILTER_LENGTH) => z.string().max(max).optional() +/** + * Inline API client credentials, or the token a saved SailPoint credential + * minted for `tenant` in their place; {@link operationSchema} requires one or + * the other. + */ const baseFields = { - clientId: requiredString('Client ID'), - clientSecret: requiredString('Client Secret', 8192), + clientId: requiredString('Client ID').optional(), + clientSecret: requiredString('Client Secret', 8192).optional(), + accessToken: requiredString('Access token', 16_384).optional(), tenant: requiredString('Tenant', 253), } +function requireAuth( + value: { accessToken?: unknown; clientId?: unknown; clientSecret?: unknown }, + ctx: z.RefinementCtx +): void { + if (value.accessToken) return + if (!value.clientId) + ctx.addIssue({ code: 'custom', path: ['clientId'], message: 'Client ID is required' }) + if (!value.clientSecret) { + ctx.addIssue({ code: 'custom', path: ['clientSecret'], message: 'Client Secret is required' }) + } +} + const offsetField = z.coerce.number().int().min(0).optional() const countField = z.boolean().optional() const limitField = (max: number) => z.coerce.number().int().min(0).max(max).optional() @@ -203,7 +221,9 @@ const reviewDecisionSchema = z }) function operationSchema(operation: T, fields: S) { - return z.object({ ...baseFields, operation: z.literal(operation), ...fields }) + return z + .object({ ...baseFields, operation: z.literal(operation), ...fields }) + .superRefine(requireAuth) } const schemas = { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index f2975fd379e..17d5d1c6d50 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -144,6 +144,7 @@ import { RipplingIcon, RocketlaneIcon, RootlyIcon, + SailPointIcon, SalesforceIcon, SendgridIcon, SentryIcon, @@ -1661,6 +1662,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'fivetran', }, + sailpoint: { + name: 'SailPoint', + icon: SailPointIcon, + services: { + sailpoint: { + name: 'SailPoint', + description: 'Govern identities and access in SailPoint Identity Security Cloud.', + providerId: 'sailpoint', + serviceAccountProviderId: 'sailpoint-service-account', + authType: 'service_account', + icon: SailPointIcon, + baseProviderIcon: SailPointIcon, + scopes: [], + }, + }, + defaultService: 'sailpoint', + }, datadog: { name: 'Datadog', icon: DatadogIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e191ff4fea4..0eeb52abaa7 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -622,6 +622,7 @@ describe('ordinary tool credential binding', () => { ['greenhouse', 'greenhouse-service-account'], ['crowdstrike', 'crowdstrike-service-account'], ['datadog', 'datadog-service-account'], + ['sailpoint', 'sailpoint-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 4d64f237ee0..896d04c610e 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -115,6 +115,7 @@ export type OAuthProvider = | 'ramp' | 'helpscout' | 'fivetran' + | 'sailpoint' | 'datadog' | 'crowdstrike' | 'vanta' @@ -293,6 +294,7 @@ export type OAuthService = | 'ramp' | 'helpscout' | 'fivetran' + | 'sailpoint' | 'datadog' | 'crowdstrike' | 'vanta' diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 9b1ce4b81d1..127f867a9cf 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -92,6 +92,7 @@ import { RipplingBlock, RipplingV2Block } from '@/blocks/blocks/rippling' import { RocketlaneBlock, RocketlaneV2Block } from '@/blocks/blocks/rocketlane' import { RootlyBlock, RootlyV2Block } from '@/blocks/blocks/rootly' import { S3Block, S3V2Block } from '@/blocks/blocks/s3' +import { SailPointBlock, SailPointV2Block } from '@/blocks/blocks/sailpoint' import { SecretsManagerBlock, SecretsManagerV2Block } from '@/blocks/blocks/secrets_manager' import { SendGridBlock, SendGridV2Block } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryV2Block } from '@/blocks/blocks/sentry' @@ -352,6 +353,11 @@ const VERSIONS: Array<{ { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, + { + legacy: SailPointBlock, + successor: SailPointV2Block, + secretIds: ['tenant', 'clientId', 'clientSecret'], + }, { legacy: DatadogBlock, successor: DatadogV2Block, diff --git a/apps/sim/tools/sailpoint/common.ts b/apps/sim/tools/sailpoint/common.ts index aeb5388fe05..a3f20a2329d 100644 --- a/apps/sim/tools/sailpoint/common.ts +++ b/apps/sim/tools/sailpoint/common.ts @@ -1,6 +1,7 @@ import { toError } from '@sim/utils/errors' import { filterUndefined } from '@sim/utils/object' import { STANDARD_LIMIT_MAX } from '@/lib/internal/sailpoint/schema' +import type { SailPointCredentials } from '@/tools/sailpoint/types' import type { ToolConfig, ToolOutputProperty } from '@/tools/types' /** Credentials shared by every SailPoint Identity Security Cloud operation. */ @@ -24,6 +25,12 @@ export const sailpointCredentialParams = { description: 'SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected SailPoint credential', + }, } as const satisfies ToolConfig['params'] /** Standard collection pagination used by SailPoint service-semver list APIs. */ @@ -72,21 +79,21 @@ export const sailpointRolePaginationParams = { count: sailpointPaginationParams.count, } as const satisfies ToolConfig['params'] -interface SailPointCredentialsLike { - clientId: string - clientSecret: string - tenant: string -} - -/** Builds the private input consumed by the SailPoint internal operation handler. */ +/** + * Builds the private input consumed by the SailPoint internal operation + * handler: the token a saved credential minted for `tenant`, or the inline + * client id and secret the handler exchanges for one. + */ export function createSailPointOperationInput( operation: string, - params: SailPointCredentialsLike, + params: SailPointCredentials, input: Record = {} ): Record { - const clientId = requireNonEmptyString(params.clientId, 'Client ID') - const clientSecret = requireNonEmptyString(params.clientSecret, 'Client secret') const tenant = requireNonEmptyString(params.tenant, 'Tenant') + const accessToken = optionalNonEmptyString(params.accessToken) + if (accessToken) return filterUndefined({ operation, accessToken, tenant, ...input }) + const clientId = requireNonEmptyString(params.clientId ?? '', 'Client ID') + const clientSecret = requireNonEmptyString(params.clientSecret ?? '', 'Client secret') return filterUndefined({ operation, clientId, clientSecret, tenant, ...input }) } diff --git a/apps/sim/tools/sailpoint/definitions.ts b/apps/sim/tools/sailpoint/definitions.ts index 36074333580..184e89838ad 100644 --- a/apps/sim/tools/sailpoint/definitions.ts +++ b/apps/sim/tools/sailpoint/definitions.ts @@ -74,6 +74,7 @@ import type { SailPointSegmentedListParams, SailPointSourceItemRef, } from '@/tools/sailpoint/types' +import { SAILPOINT_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig, ToolConfig, ToolParameterItemSchema } from '@/tools/types' type ToolParams = ToolConfig['params'] @@ -217,6 +218,7 @@ function defineSailPointTool

({ name, description, version: '1.0.0', + oauth: SAILPOINT_CREDENTIAL_OAUTH, params, operation: { input: (params) => createSailPointOperationInput(id, params, input(params)) }, transformResponse: (response) => unwrapSailPointOutput(response), diff --git a/apps/sim/tools/sailpoint/types.ts b/apps/sim/tools/sailpoint/types.ts index d671c3798ee..fb7f489c27c 100644 --- a/apps/sim/tools/sailpoint/types.ts +++ b/apps/sim/tools/sailpoint/types.ts @@ -1,6 +1,11 @@ +/** + * Inline API client credentials, or the token a saved SailPoint credential + * minted, with the tenant it was minted for, in their place. + */ export interface SailPointCredentials { - clientId: string - clientSecret: string + clientId?: string + clientSecret?: string + accessToken?: string tenant: string } diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 0f8c7613104..204d3660e8c 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -145,6 +145,16 @@ export const DATADOG_API_KEY_CREDENTIAL_OAUTH = tokenCredentialOAuth('datadog', site: 'domain', }) +/** + * Credential policy for every SailPoint tool. A saved SailPoint API client + * resolves to a token minted at its tenant; the credential fills the token and + * that tenant's API host, and the token replaces the inline client id and secret. + */ +export const SAILPOINT_CREDENTIAL_OAUTH = clientCredentialOAuth('sailpoint', { tenant: 'domain' }, [ + 'clientId', + 'clientSecret', +]) + /** Token credential policies for tools that take an inline key or a saved token credential. */ export const AFFINITY_CREDENTIAL_OAUTH = tokenCredentialOAuth('affinity', { apiKey: 'accessToken' }) export const AGENTMAIL_CREDENTIAL_OAUTH = tokenCredentialOAuth('agentmail', { @@ -455,6 +465,7 @@ export const SAVED_CREDENTIAL_OAUTH = { rippling: RIPPLING_CREDENTIAL_OAUTH, rocketlane: ROCKETLANE_CREDENTIAL_OAUTH, rootly: ROOTLY_CREDENTIAL_OAUTH, + sailpoint: SAILPOINT_CREDENTIAL_OAUTH, sendgrid: SENDGRID_CREDENTIAL_OAUTH, sentry: SENTRY_CREDENTIAL_OAUTH, similarweb: SIMILARWEB_CREDENTIAL_OAUTH, From 3707db95a04dc22dd69955eea0a8a2b363cd7226 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:02:45 -0800 Subject: [PATCH 44/83] feat(credentials): saved ZoomInfo client credentials and credential-only block A ZoomInfo credential holds a client credentials app's client id and secret. The tools already authenticate with that grant (Basic auth at api.zoominfo.com/gtm/oauth/v1/token), so the minter shares the legacy block's exchange, now requestZoomInfoToken: a refusal throws ZoomInfoTokenError, which the operation answers exactly as before (HTTP 500 naming the provider status) and the minter maps to invalid_credentials for a 4xx or provider_unavailable for throttling, 5xx, or network failures. - Save verifies by minting a token only; the token endpoint uses no credits and no data endpoint is called. Granted scopes are recorded as metadata. - A client id with a colon or control characters is refused before any request, since it would corrupt the Basic credential. - The token exchange and API calls refuse redirects (maxRedirects: 0); both go only to the fixed api.zoominfo.com host. - The credential fills the tools' hidden accessToken, which replaces the inline client id and secret; zoominfo_v2 is the credential-only successor, and the legacy block keeps its inline credentials and is hidden from the toolbar. Search filters named like hosts are recorded as request data in the saved-credential contract check. --- .../content/docs/integrations/zoominfo.mdx | 2 +- apps/sim/blocks/blocks/zoominfo.ts | 17 +++ apps/sim/blocks/registry-maps.ts | 3 +- .../client-credential-accounts/descriptors.ts | 23 ++++ .../minters/zoominfo.test.ts | 82 ++++++++++++++ .../minters/zoominfo.ts | 95 ++++++++++++++++ .../client-credential-accounts/server.ts | 3 + apps/sim/lib/internal/zoominfo/client.test.ts | 53 +++++++++ apps/sim/lib/internal/zoominfo/client.ts | 103 +++++++++++++++--- apps/sim/lib/internal/zoominfo/operations.ts | 9 +- apps/sim/lib/internal/zoominfo/schema.ts | 9 +- apps/sim/lib/oauth/oauth.ts | 18 +++ apps/sim/lib/oauth/token-resolution.test.ts | 1 + apps/sim/lib/oauth/types.ts | 2 + apps/sim/scripts/saved-credential-contract.ts | 3 + apps/sim/serializer/saved-credentials.test.ts | 6 + apps/sim/tools/shared/saved-credentials.ts | 11 ++ apps/sim/tools/zoominfo/enrich_companies.ts | 8 ++ apps/sim/tools/zoominfo/enrich_contacts.ts | 8 ++ apps/sim/tools/zoominfo/search_companies.ts | 8 ++ apps/sim/tools/zoominfo/search_contacts.ts | 8 ++ apps/sim/tools/zoominfo/search_intent.ts | 8 ++ apps/sim/tools/zoominfo/search_news.ts | 8 ++ apps/sim/tools/zoominfo/types.ts | 9 +- 24 files changed, 471 insertions(+), 26 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts create mode 100644 apps/sim/lib/internal/zoominfo/client.test.ts diff --git a/apps/docs/content/docs/integrations/zoominfo.mdx b/apps/docs/content/docs/integrations/zoominfo.mdx index aedf441c1ba..80b7f237b9f 100644 --- a/apps/docs/content/docs/integrations/zoominfo.mdx +++ b/apps/docs/content/docs/integrations/zoominfo.mdx @@ -16,7 +16,7 @@ Use [ZoomInfo](https://www.zoominfo.com/) to search company and contact data, en **Search Intent** returns companies researching selected topics, signal scores, and recommended contacts. **Search News** filters by category, source URL, or publication date. -Supply a client ID and secret. Sim exchanges them for a short-lived OAuth bearer token on each call; you do not supply the access token yourself. +Connect ZoomInfo once as a ZoomInfo credential: create a client credentials app in the ZoomInfo Developer Portal, choose the integration user it acts as, and add its client ID and client secret. Sim checks the credential by requesting a token, which uses no credits, and requests a new token before each one expires. Every call acts as the app's integration user with the scopes configured on the app, and data calls consume credits as usual. Workflows built with the earlier ZoomInfo block keep the client ID and secret they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/sim/blocks/blocks/zoominfo.ts b/apps/sim/blocks/blocks/zoominfo.ts index 9f771f46014..023fc34fcba 100644 --- a/apps/sim/blocks/blocks/zoominfo.ts +++ b/apps/sim/blocks/blocks/zoominfo.ts @@ -1,9 +1,12 @@ import { ZoomInfoIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const ZoomInfoBlock: BlockConfig = { type: 'zoominfo', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'zoominfo_v2' }, name: 'ZoomInfo', description: 'Search and enrich B2B company and contact data with ZoomInfo.', authMode: AuthMode.ApiKey, @@ -687,3 +690,17 @@ export const ZoomInfoBlockMeta = { }, ], } as const satisfies BlockMeta + +export const ZoomInfoV2Block: BlockConfig = { + ...ZoomInfoBlock, + type: 'zoominfo_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ZoomInfoBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'zoominfo', + title: 'ZoomInfo Credential', + }), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 1a92d4d4090..e0c3ab420b8 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -474,7 +474,7 @@ import { ZepBlock, ZepBlockMeta, ZepV2Block } from '@/blocks/blocks/zep' import { ZeroBounceBlock, ZeroBounceBlockMeta } from '@/blocks/blocks/zerobounce' import { ZohoDeskBlock, ZohoDeskBlockMeta } from '@/blocks/blocks/zoho-desk' import { ZoomBlock, ZoomBlockMeta } from '@/blocks/blocks/zoom' -import { ZoomInfoBlock, ZoomInfoBlockMeta } from '@/blocks/blocks/zoominfo' +import { ZoomInfoBlock, ZoomInfoBlockMeta, ZoomInfoV2Block } from '@/blocks/blocks/zoominfo' import type { BlockConfig, BlockMeta } from '@/blocks/types' /** All block configs keyed by block type. The execution source of truth. */ @@ -974,6 +974,7 @@ export const BLOCK_REGISTRY: Record = { zoho_desk: ZohoDeskBlock, zoom: ZoomBlock, zoominfo: ZoomInfoBlock, + zoominfo_v2: ZoomInfoV2Block, } /** diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index fd3924bfb1e..5c0447dae05 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -142,6 +142,7 @@ export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' a export const CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID = 'crowdstrike-service-account' as const export const DATADOG_SERVICE_ACCOUNT_PROVIDER_ID = 'datadog-service-account' as const export const SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID = 'sailpoint-service-account' as const +export const ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID = 'zoominfo-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -171,6 +172,7 @@ export type ClientCredentialAccountProviderId = | typeof CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID | typeof DATADOG_SERVICE_ACCOUNT_PROVIDER_ID | typeof SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -552,6 +554,27 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', }, + [ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ZoomInfo', + connectNoun: 'API app', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Create a client credentials app in the ZoomInfo Developer Portal. Its integration user and scopes apply to every call.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/zoominfo', + }, [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'SailPoint', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts new file mode 100644 index 00000000000..446f1ec8a7e --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts @@ -0,0 +1,82 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { mintZoomInfoServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoominfo' + +const { mockSecureFetchWithValidation } = inputValidationMockFns +const FIELDS = { clientId: 'zi-client', clientSecret: 'zi-secret', orgId: '' } + +describe('ZoomInfo client credentials exchange', () => { + beforeEach(() => { + mockSecureFetchWithValidation.mockReset() + }) + + it('mints at the documented token endpoint with Basic auth, refusing redirects, and calls nothing else', async () => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ access_token: 'zi-access', expires_in: 1000, scope: 'api:data:company' }) + ) + + const result = await mintZoomInfoServiceAccountToken(FIELDS) + + expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + const [url, options] = mockSecureFetchWithValidation.mock.calls[0] + expect(url).toBe('https://api.zoominfo.com/gtm/oauth/v1/token') + expect(options).toMatchObject({ method: 'POST', maxRedirects: 0 }) + expect(options.headers.Authorization).toBe( + `Basic ${Buffer.from('zi-client:zi-secret').toString('base64')}` + ) + expect(Object.fromEntries(new URLSearchParams(options.body))).toEqual({ + grant_type: 'client_credentials', + }) + expect(result).toMatchObject({ accessToken: 'zi-access', expiresInSeconds: 1000 }) + expect(result.identity?.storedMetadata).toMatchObject({ grantedScopes: 'api:data:company' }) + expect(JSON.stringify(result.identity)).not.toContain('zi-secret') + expect(JSON.stringify(result.identity)).not.toContain('zi-access') + }) + + it.each([ + ['clientId', 'client:with-colon'], + ['clientId', 'client\r\nX-Injected: 1'], + ['clientSecret', ''], + ] as const)('refuses an unusable %s before any request', async (field, value) => { + await expect( + mintZoomInfoServiceAccountToken({ ...FIELDS, [field]: value }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ error: 'invalid_client' }, { status }) + ) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('treats a refused redirect or network failure as a provider failure', async () => { + mockSecureFetchWithValidation.mockRejectedValue(new Error('Too many redirects (max: 0)')) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockSecureFetchWithValidation.mockResolvedValue(Response.json({ expires_in: 1000 })) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts new file mode 100644 index 00000000000..51fb8021c52 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts @@ -0,0 +1,95 @@ +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + providerFailureCode, + providerFailureReason, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { requestZoomInfoToken, ZoomInfoTokenError } from '@/lib/internal/zoominfo/client' + +const STEP = 'zoominfo_token_mint' +const MAX_CREDENTIAL_BYTES = 1_024 +const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/ + +function assertCredentialText(value: string, label: string): void { + if ( + !value || + FORBIDDEN_CREDENTIAL_TEXT.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_CREDENTIAL_BYTES + ) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: `${label} is invalid`, + }) + } +} + +/** + * Mints a ZoomInfo API access token for a client-credentials app through the + * same exchange the legacy block uses: `POST + * https://api.zoominfo.com/gtm/oauth/v1/token` with `grant_type=client_credentials` + * and the client id and secret in HTTP Basic auth, redirects refused. The token + * acts as the app's integration user with the app's configured scopes; its + * lifetime comes from `expires_in`. + * + * The connect-time check is the mint alone: the token endpoint consumes no + * credits, and every data endpoint does, so none is called to verify. + * + * @see https://docs.zoominfo.com/docs/client-credentials-flow + */ +export async function mintZoomInfoServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'ZoomInfo') + assertCredentialText(fields.clientId, 'client ID') + // A colon would end the Basic auth user and shift the rest into the password. + if (fields.clientId.includes(':')) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID is invalid', + }) + } + assertCredentialText(clientSecret, 'client secret') + + let minted: Awaited> + try { + minted = await requestZoomInfoToken( + { clientId: fields.clientId, clientSecret }, + { signal: options?.signal } + ) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof ZoomInfoTokenError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.tokenStatus), + error.tokenStatus, + { step: STEP } + ) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: providerFailureReason(error), + }) + } + + const grantedScopes = minted.scope?.split(/\s+/).filter(Boolean) + return { + accessToken: minted.accessToken, + expiresInSeconds: minted.expiresInSeconds, + grantedScopes, + identity: { + displayName: 'ZoomInfo API credential', + principal: null, + auditMetadata: { zoominfoClientId: fields.clientId }, + ...(grantedScopes?.length + ? { storedMetadata: { grantedScopes: grantedScopes.join(' ') } } + : {}), + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index f6361a15e19..c3c86149377 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -21,6 +21,7 @@ import { VANTA_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, + ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' @@ -39,6 +40,7 @@ import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-cred import { mintVantaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/vanta' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' +import { mintZoomInfoServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoominfo' import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' /** Credential fields normalized according to the provider descriptor; passwords retain exact bytes. */ @@ -182,6 +184,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: mintCrowdStrikeServiceAccountToken, [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: mintDatadogServiceAccountToken, [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: mintSailPointServiceAccountToken, + [ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomInfoServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/internal/zoominfo/client.test.ts b/apps/sim/lib/internal/zoominfo/client.test.ts new file mode 100644 index 00000000000..deae32584f4 --- /dev/null +++ b/apps/sim/lib/internal/zoominfo/client.test.ts @@ -0,0 +1,53 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { requestZoomInfo } from '@/lib/internal/zoominfo/client' + +const { mockSecureFetchWithValidation } = inputValidationMockFns +const REQUEST = { path: '/data/v1/companies/search', method: 'POST' as const, body: {} } + +describe('ZoomInfo requests', () => { + beforeEach(() => { + mockSecureFetchWithValidation.mockReset() + }) + + it("sends a saved credential's token to the API without a token exchange", async () => { + mockSecureFetchWithValidation.mockResolvedValue(Response.json({ data: [] })) + + await expect( + requestZoomInfo({ ...REQUEST, accessToken: 'minted-token' }, 'request-1') + ).resolves.toEqual({ status: 200, data: { data: [] } }) + + expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + const [url, options] = mockSecureFetchWithValidation.mock.calls[0] + expect(url).toBe('https://api.zoominfo.com/gtm/data/v1/companies/search') + expect(options).toMatchObject({ maxRedirects: 0 }) + expect(options.headers.Authorization).toBe('Bearer minted-token') + }) + + it('refuses a request carrying neither inline credentials nor a token, before any call', async () => { + await expect( + requestZoomInfo({ ...REQUEST, clientId: 'client-only' }, 'request-2') + ).rejects.toMatchObject({ status: 400 }) + expect(mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + + it('keeps reporting an inline token refusal exactly as before', async () => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ error: 'invalid_client' }, { status: 401 }) + ) + + await expect( + requestZoomInfo({ ...REQUEST, clientId: 'client', clientSecret: 'secret' }, 'request-3') + ).rejects.toMatchObject({ + message: 'ZoomInfo token request failed: HTTP 401', + status: 500, + providerStatus: undefined, + }) + }) +}) diff --git a/apps/sim/lib/internal/zoominfo/client.ts b/apps/sim/lib/internal/zoominfo/client.ts index d98e19ea868..05ecdd4046c 100644 --- a/apps/sim/lib/internal/zoominfo/client.ts +++ b/apps/sim/lib/internal/zoominfo/client.ts @@ -18,6 +18,8 @@ const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 const MAX_TOKEN_RESPONSE_BYTES = 256 * 1024 const TOKEN_CACHE_MAX_ENTRIES = 500 const TOKEN_SAFETY_WINDOW_MS = 60_000 +/** Lifetime assumed when a token response carries no usable `expires_in`. */ +const DEFAULT_TOKEN_LIFETIME_SECONDS = 3300 interface CachedToken { accessToken: string @@ -42,7 +44,37 @@ export class ZoomInfoOperationError extends Error { } } -function tokenCacheKey(auth: ZoomInfoAuth): string { +/** + * Raised when ZoomInfo's token endpoint refuses an exchange. Answers the + * operation exactly as every token failure always has (HTTP 500 naming the + * provider status), and carries that status for the saved-credential minter. + */ +export class ZoomInfoTokenError extends ZoomInfoOperationError { + constructor(readonly tokenStatus: number) { + super(`ZoomInfo token request failed: HTTP ${tokenStatus}`, 500) + this.name = 'ZoomInfoTokenError' + } +} + +interface ZoomInfoClientCredentials { + clientId: string + clientSecret: string +} + +/** + * The token a saved credential minted, or the inline client credentials to + * mint one with. Refuses input carrying neither. + */ +function resolveZoomInfoAuth( + auth: ZoomInfoAuth +): { accessToken: string } | { credentials: ZoomInfoClientCredentials } { + if (auth.accessToken) return { accessToken: auth.accessToken } + if (!auth.clientId) throw new ZoomInfoOperationError('clientId is required', 400) + if (!auth.clientSecret) throw new ZoomInfoOperationError('clientSecret is required', 400) + return { credentials: { clientId: auth.clientId, clientSecret: auth.clientSecret } } +} + +function tokenCacheKey(auth: ZoomInfoClientCredentials): string { const secretHash = createHash('sha256').update(auth.clientSecret).digest('hex').slice(0, 16) return `${auth.clientId}::${secretHash}` } @@ -57,18 +89,20 @@ function rememberToken(key: string, token: CachedToken): void { } } -async function fetchAccessToken( - auth: ZoomInfoAuth, - requestId: string, - signal?: AbortSignal -): Promise { +/** + * Exchanges client credentials for an access token with the client + * credentials grant at ZoomInfo's token endpoint, credentials in HTTP Basic + * auth as ZoomInfo recommends, redirects refused. A refusal throws + * {@link ZoomInfoTokenError}; the endpoint consumes no credits. + * + * @see https://docs.zoominfo.com/docs/client-credentials-flow + */ +export async function requestZoomInfoToken( + auth: ZoomInfoClientCredentials, + options: { requestId?: string; signal?: AbortSignal } = {} +): Promise<{ accessToken: string; expiresInSeconds: number; scope?: string }> { + const { requestId, signal } = options signal?.throwIfAborted() - const cacheKey = tokenCacheKey(auth) - const cached = TOKEN_CACHE.get(cacheKey) - if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { - return cached.accessToken - } - const tokenUrl = assertSafeZoomInfoUrl(ZOOMINFO_TOKEN_URL, 'tokenUrl').toString() const basic = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64') const response = await secureFetchWithValidation( @@ -84,6 +118,7 @@ async function fetchAccessToken( body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(), timeout: OUTBOUND_FETCH_TIMEOUT_MS, maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES, + maxRedirects: 0, signal, }, 'tokenUrl' @@ -93,17 +128,44 @@ async function fetchAccessToken( if (!response.ok) { const text = await response.text().catch(() => '') logger.warn('ZoomInfo token fetch failed', { requestId, status: response.status, error: text }) - throw new ZoomInfoOperationError(`ZoomInfo token request failed: HTTP ${response.status}`, 500) + throw new ZoomInfoTokenError(response.status) + } + const data = (await response.json()) as { + access_token?: string + expires_in?: number + scope?: string } - const data = (await response.json()) as { access_token?: string; expires_in?: number } if (!data.access_token) { throw new ZoomInfoOperationError('ZoomInfo token response missing access_token', 500) } - rememberToken(cacheKey, { + return { accessToken: data.access_token, - expiresAt: Date.now() + (data.expires_in ?? 3300) * 1000, + expiresInSeconds: + typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) && data.expires_in > 0 + ? data.expires_in + : DEFAULT_TOKEN_LIFETIME_SECONDS, + ...(typeof data.scope === 'string' ? { scope: data.scope } : {}), + } +} + +async function fetchAccessToken( + auth: ZoomInfoClientCredentials, + requestId: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const cacheKey = tokenCacheKey(auth) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return cached.accessToken + } + + const { accessToken, expiresInSeconds } = await requestZoomInfoToken(auth, { + requestId, + signal, }) - return data.access_token + rememberToken(cacheKey, { accessToken, expiresAt: Date.now() + expiresInSeconds * 1000 }) + return accessToken } function buildApiUrl(input: ZoomInfoProviderRequest): string { @@ -174,6 +236,7 @@ async function invokeZoomInfo( : undefined, timeout: OUTBOUND_FETCH_TIMEOUT_MS, maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + maxRedirects: 0, signal, }, 'apiUrl' @@ -196,7 +259,11 @@ export async function requestZoomInfo( requestId: string, signal?: AbortSignal ): Promise<{ status: number; data: unknown }> { - const accessToken = await fetchAccessToken(input, requestId, signal) + const auth = resolveZoomInfoAuth(input) + const accessToken = + 'accessToken' in auth + ? auth.accessToken + : await fetchAccessToken(auth.credentials, requestId, signal) const invocation = await invokeZoomInfo(input, accessToken, signal) if (invocation.status < 200 || invocation.status >= 300) { throw new ZoomInfoOperationError( diff --git a/apps/sim/lib/internal/zoominfo/operations.ts b/apps/sim/lib/internal/zoominfo/operations.ts index 5e0e6f54cec..e5c04378d93 100644 --- a/apps/sim/lib/internal/zoominfo/operations.ts +++ b/apps/sim/lib/internal/zoominfo/operations.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { requestZoomInfo } from '@/lib/internal/zoominfo/client' import { + type ZoomInfoAuth, type ZoomInfoEnrichCompaniesInput, type ZoomInfoEnrichContactsInput, type ZoomInfoProviderRequest, @@ -107,8 +108,12 @@ function toNumberOrUndefined(value: unknown): number | undefined { return Number.isFinite(number) ? number : undefined } -function authInput(input: { clientId: string; clientSecret: string }) { - return { clientId: input.clientId, clientSecret: input.clientSecret } +function authInput(input: ZoomInfoAuth) { + return { + clientId: input.clientId, + clientSecret: input.clientSecret, + accessToken: input.accessToken, + } } function paginationQuery(page: unknown, rpp: unknown): Record | undefined { diff --git a/apps/sim/lib/internal/zoominfo/schema.ts b/apps/sim/lib/internal/zoominfo/schema.ts index 60f57e34a1f..212d1d06374 100644 --- a/apps/sim/lib/internal/zoominfo/schema.ts +++ b/apps/sim/lib/internal/zoominfo/schema.ts @@ -4,9 +4,14 @@ import { z } from 'zod' export const ZOOMINFO_API_BASE = 'https://api.zoominfo.com/gtm' export const ZOOMINFO_TOKEN_URL = `${ZOOMINFO_API_BASE}/oauth/v1/token` +/** + * Inline client credentials, or the token a saved ZoomInfo credential minted + * in their place. `resolveZoomInfoAuth` requires one or the other. + */ export const zoomInfoAuthSchema = z.object({ - clientId: z.string().min(1, 'clientId is required'), - clientSecret: z.string().min(1, 'clientSecret is required'), + clientId: z.string().min(1, 'clientId is required').optional(), + clientSecret: z.string().min(1, 'clientSecret is required').optional(), + accessToken: z.string().min(1).optional(), }) export const zoomInfoProviderRequestSchema = zoomInfoAuthSchema.extend({ diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 17d5d1c6d50..d39298f816a 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -184,6 +184,7 @@ import { ZepIcon, ZohoDeskIcon, ZoomIcon, + ZoomInfoIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' @@ -1662,6 +1663,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'fivetran', }, + zoominfo: { + name: 'ZoomInfo', + icon: ZoomInfoIcon, + services: { + zoominfo: { + name: 'ZoomInfo', + description: 'Search and enrich B2B company and contact data with ZoomInfo.', + providerId: 'zoominfo', + serviceAccountProviderId: 'zoominfo-service-account', + authType: 'service_account', + icon: ZoomInfoIcon, + baseProviderIcon: ZoomInfoIcon, + scopes: [], + }, + }, + defaultService: 'zoominfo', + }, sailpoint: { name: 'SailPoint', icon: SailPointIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 0eeb52abaa7..5e175ff1266 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -623,6 +623,7 @@ describe('ordinary tool credential binding', () => { ['crowdstrike', 'crowdstrike-service-account'], ['datadog', 'datadog-service-account'], ['sailpoint', 'sailpoint-service-account'], + ['zoominfo', 'zoominfo-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 896d04c610e..43c1451b96a 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -115,6 +115,7 @@ export type OAuthProvider = | 'ramp' | 'helpscout' | 'fivetran' + | 'zoominfo' | 'sailpoint' | 'datadog' | 'crowdstrike' @@ -294,6 +295,7 @@ export type OAuthService = | 'ramp' | 'helpscout' | 'fivetran' + | 'zoominfo' | 'sailpoint' | 'datadog' | 'crowdstrike' diff --git a/apps/sim/scripts/saved-credential-contract.ts b/apps/sim/scripts/saved-credential-contract.ts index c99d095088f..aca2505a1da 100644 --- a/apps/sim/scripts/saved-credential-contract.ts +++ b/apps/sim/scripts/saved-credential-contract.ts @@ -53,6 +53,9 @@ const OPERATION_PAYLOAD_PARAMS: Readonly> = { fireflies_upload_audio: ['audioUrl'], // A filter on the asset's cloud account; the token goes to the credential's Vanta API domain. vanta_list_vulnerable_assets: ['assetExternalAccountId'], + // Search filters sent in the request body; the token goes only to api.zoominfo.com. + zoominfo_search_companies: ['companyWebsite', 'metroRegion'], + zoominfo_search_news: ['url'], // The status page's own domain, sent as a form field to the UptimeRobot API. uptimerobot_create_psp: ['customDomain'], uptimerobot_update_psp: ['customDomain'], diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 127f867a9cf..d97989017d0 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -124,6 +124,7 @@ import { WhatsAppBlock, WhatsAppV2Block } from '@/blocks/blocks/whatsapp' import { YouComBlock, YouComV2Block } from '@/blocks/blocks/youcom' import { YouTubeBlock, YouTubeV2Block } from '@/blocks/blocks/youtube' import { ZepBlock, ZepV2Block } from '@/blocks/blocks/zep' +import { ZoomInfoBlock, ZoomInfoV2Block } from '@/blocks/blocks/zoominfo' import type { BlockConfig } from '@/blocks/types' import { Serializer } from '@/serializer' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -353,6 +354,11 @@ const VERSIONS: Array<{ { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, + { + legacy: ZoomInfoBlock, + successor: ZoomInfoV2Block, + secretIds: ['clientId', 'clientSecret'], + }, { legacy: SailPointBlock, successor: SailPointV2Block, diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 204d3660e8c..9922f677893 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -155,6 +155,16 @@ export const SAILPOINT_CREDENTIAL_OAUTH = clientCredentialOAuth('sailpoint', { t 'clientSecret', ]) +/** + * Credential policy for every ZoomInfo tool. A saved ZoomInfo app resolves to + * a token for ZoomInfo's single API host, which replaces the inline client id + * and secret. + */ +export const ZOOMINFO_CREDENTIAL_OAUTH = clientCredentialOAuth('zoominfo', {}, [ + 'clientId', + 'clientSecret', +]) + /** Token credential policies for tools that take an inline key or a saved token credential. */ export const AFFINITY_CREDENTIAL_OAUTH = tokenCredentialOAuth('affinity', { apiKey: 'accessToken' }) export const AGENTMAIL_CREDENTIAL_OAUTH = tokenCredentialOAuth('agentmail', { @@ -490,6 +500,7 @@ export const SAVED_CREDENTIAL_OAUTH = { youcom: YOUCOM_CREDENTIAL_OAUTH, youtube: YOUTUBE_CREDENTIAL_OAUTH, zep: ZEP_CREDENTIAL_OAUTH, + zoominfo: ZOOMINFO_CREDENTIAL_OAUTH, } as const export type SavedCredentialService = keyof typeof SAVED_CREDENTIAL_OAUTH diff --git a/apps/sim/tools/zoominfo/enrich_companies.ts b/apps/sim/tools/zoominfo/enrich_companies.ts index 33a91f4222c..ef9eea42cf6 100644 --- a/apps/sim/tools/zoominfo/enrich_companies.ts +++ b/apps/sim/tools/zoominfo/enrich_companies.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoEnrichCompaniesParams, @@ -14,6 +15,7 @@ export const zoominfoEnrichCompaniesTool: InternalToolConfig< description: 'Enrich up to 25 companies in one request with detailed firmographics, industry, financials, and more.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -28,6 +30,12 @@ export const zoominfoEnrichCompaniesTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, matchCompanyInput: { type: 'string', required: true, diff --git a/apps/sim/tools/zoominfo/enrich_contacts.ts b/apps/sim/tools/zoominfo/enrich_contacts.ts index 8ec8b8ce831..9d7a63b009e 100644 --- a/apps/sim/tools/zoominfo/enrich_contacts.ts +++ b/apps/sim/tools/zoominfo/enrich_contacts.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoEnrichContactsParams, @@ -14,6 +15,7 @@ export const zoominfoEnrichContactsTool: InternalToolConfig< description: 'Enrich up to 25 contacts in one request with verified emails, phone numbers, job details, and more.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -28,6 +30,12 @@ export const zoominfoEnrichContactsTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, matchPersonInput: { type: 'string', required: true, diff --git a/apps/sim/tools/zoominfo/search_companies.ts b/apps/sim/tools/zoominfo/search_companies.ts index 3b125e86dd8..a7ffa81ba5f 100644 --- a/apps/sim/tools/zoominfo/search_companies.ts +++ b/apps/sim/tools/zoominfo/search_companies.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoSearchCompaniesParams, @@ -18,6 +19,7 @@ export const zoominfoSearchCompaniesTool: InternalToolConfig< name: 'ZoomInfo Search Companies', description: 'Search the ZoomInfo company database by name, industry, location, and size.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -32,6 +34,12 @@ export const zoominfoSearchCompaniesTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, companyName: { type: 'string', required: false, diff --git a/apps/sim/tools/zoominfo/search_contacts.ts b/apps/sim/tools/zoominfo/search_contacts.ts index 345e78964bc..43da9073349 100644 --- a/apps/sim/tools/zoominfo/search_contacts.ts +++ b/apps/sim/tools/zoominfo/search_contacts.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoSearchContactsParams, @@ -19,6 +20,7 @@ export const zoominfoSearchContactsTool: InternalToolConfig< description: 'Search ZoomInfo for contacts (people) by name, job title, company, and other filters. Does not return emails or phone numbers — use Enrich Contacts for engagement data.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +35,12 @@ export const zoominfoSearchContactsTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, firstName: { type: 'string', required: false, diff --git a/apps/sim/tools/zoominfo/search_intent.ts b/apps/sim/tools/zoominfo/search_intent.ts index db6b84ee031..c88fe41ff52 100644 --- a/apps/sim/tools/zoominfo/search_intent.ts +++ b/apps/sim/tools/zoominfo/search_intent.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoSearchIntentParams, @@ -18,6 +19,7 @@ export const zoominfoSearchIntentTool: InternalToolConfig< name: 'ZoomInfo Search Intent', description: 'Search for companies showing intent signals on specific topics.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -32,6 +34,12 @@ export const zoominfoSearchIntentTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, topics: { type: 'string', required: true, diff --git a/apps/sim/tools/zoominfo/search_news.ts b/apps/sim/tools/zoominfo/search_news.ts index 622321d8ece..66abb29f59c 100644 --- a/apps/sim/tools/zoominfo/search_news.ts +++ b/apps/sim/tools/zoominfo/search_news.ts @@ -1,3 +1,4 @@ +import { ZOOMINFO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' import type { ZoomInfoSearchNewsParams, ZoomInfoSearchNewsResponse } from '@/tools/zoominfo/types' import { @@ -15,6 +16,7 @@ export const zoominfoSearchNewsTool: InternalToolConfig< name: 'ZoomInfo Search News', description: 'Search ZoomInfo news articles by category, URL, or date range.', version: '1.0.0', + oauth: ZOOMINFO_CREDENTIAL_OAUTH, params: { clientId: { @@ -29,6 +31,12 @@ export const zoominfoSearchNewsTool: InternalToolConfig< visibility: 'user-only', description: 'ZoomInfo OAuth client secret', }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Access token minted from the selected ZoomInfo credential', + }, categories: { type: 'string', required: false, diff --git a/apps/sim/tools/zoominfo/types.ts b/apps/sim/tools/zoominfo/types.ts index 83cc88819fd..d40166d845d 100644 --- a/apps/sim/tools/zoominfo/types.ts +++ b/apps/sim/tools/zoominfo/types.ts @@ -1,8 +1,13 @@ import type { ToolResponse } from '@/tools/types' +/** + * Inline client credentials, or the token a saved ZoomInfo credential minted + * in their place. + */ interface ZoomInfoBaseParams { - clientId: string - clientSecret: string + clientId?: string + clientSecret?: string + accessToken?: string } export interface ZoomInfoSearchCompaniesParams extends ZoomInfoBaseParams { From 3390f772a9f63b0494fe797ea2d53978fbbe3268 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:04:47 -0800 Subject: [PATCH 45/83] refactor(gong): share one Basic auth header builder between the tools and the credential check --- apps/sim/tools/gong/aggregate_activity.ts | 4 ++-- apps/sim/tools/gong/aggregate_by_period.ts | 4 ++-- apps/sim/tools/gong/answered_scorecards.ts | 4 ++-- apps/sim/tools/gong/ask_anything.ts | 4 ++-- apps/sim/tools/gong/assign_flow_prospects.ts | 4 ++-- apps/sim/tools/gong/create_call.ts | 4 ++-- apps/sim/tools/gong/day_by_day_activity.ts | 4 ++-- apps/sim/tools/gong/get_brief.ts | 4 ++-- apps/sim/tools/gong/get_call.ts | 4 ++-- apps/sim/tools/gong/get_call_transcript.ts | 4 ++-- apps/sim/tools/gong/get_coaching.ts | 4 ++-- apps/sim/tools/gong/get_extensive_calls.ts | 4 ++-- apps/sim/tools/gong/get_folder_content.ts | 4 ++-- apps/sim/tools/gong/get_logs.ts | 4 ++-- apps/sim/tools/gong/get_prospect_flows.ts | 4 ++-- apps/sim/tools/gong/get_user.ts | 4 ++-- apps/sim/tools/gong/interaction_stats.ts | 4 ++-- apps/sim/tools/gong/list_calls.ts | 4 ++-- apps/sim/tools/gong/list_flows.ts | 4 ++-- apps/sim/tools/gong/list_library_folders.ts | 4 ++-- apps/sim/tools/gong/list_scorecards.ts | 4 ++-- apps/sim/tools/gong/list_trackers.ts | 4 ++-- apps/sim/tools/gong/list_users.ts | 4 ++-- apps/sim/tools/gong/list_workspaces.ts | 4 ++-- apps/sim/tools/gong/lookup_email.ts | 4 ++-- apps/sim/tools/gong/lookup_phone.ts | 4 ++-- apps/sim/tools/gong/purge_email_address.ts | 4 ++-- apps/sim/tools/gong/purge_phone_number.ts | 4 ++-- apps/sim/tools/gong/unassign_flow_prospects.ts | 4 ++-- apps/sim/tools/gong/utils.ts | 8 ++++++++ 30 files changed, 66 insertions(+), 58 deletions(-) diff --git a/apps/sim/tools/gong/aggregate_activity.ts b/apps/sim/tools/gong/aggregate_activity.ts index 2ad7be602bc..985a61ea768 100644 --- a/apps/sim/tools/gong/aggregate_activity.ts +++ b/apps/sim/tools/gong/aggregate_activity.ts @@ -1,5 +1,5 @@ import type { GongAggregateActivityParams, GongAggregateActivityResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -58,7 +58,7 @@ export const aggregateActivityTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = { diff --git a/apps/sim/tools/gong/aggregate_by_period.ts b/apps/sim/tools/gong/aggregate_by_period.ts index fc4a5e2d92c..db11fb4e532 100644 --- a/apps/sim/tools/gong/aggregate_by_period.ts +++ b/apps/sim/tools/gong/aggregate_by_period.ts @@ -1,5 +1,5 @@ import type { GongAggregateByPeriodParams, GongAggregateByPeriodResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -66,7 +66,7 @@ export const aggregateByPeriodTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = { diff --git a/apps/sim/tools/gong/answered_scorecards.ts b/apps/sim/tools/gong/answered_scorecards.ts index b11ce0aef03..3f13c50e029 100644 --- a/apps/sim/tools/gong/answered_scorecards.ts +++ b/apps/sim/tools/gong/answered_scorecards.ts @@ -2,7 +2,7 @@ import type { GongAnsweredScorecardsParams, GongAnsweredScorecardsResponse, } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -82,7 +82,7 @@ export const answeredScorecardsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = {} diff --git a/apps/sim/tools/gong/ask_anything.ts b/apps/sim/tools/gong/ask_anything.ts index afcd2a3939b..3510f6b775f 100644 --- a/apps/sim/tools/gong/ask_anything.ts +++ b/apps/sim/tools/gong/ask_anything.ts @@ -1,5 +1,5 @@ import type { GongAskAnythingParams, GongAskAnythingResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -98,7 +98,7 @@ export const askAnythingTool: ToolConfig ({ - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/assign_flow_prospects.ts b/apps/sim/tools/gong/assign_flow_prospects.ts index 33ad05ea262..892cb3afd13 100644 --- a/apps/sim/tools/gong/assign_flow_prospects.ts +++ b/apps/sim/tools/gong/assign_flow_prospects.ts @@ -2,7 +2,7 @@ import type { GongAssignFlowProspectsParams, GongAssignFlowProspectsResponse, } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -54,7 +54,7 @@ export const assignFlowProspectsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => ({ flowId: params.flowId.trim(), diff --git a/apps/sim/tools/gong/create_call.ts b/apps/sim/tools/gong/create_call.ts index efdacdab3b8..f65c74c9b0f 100644 --- a/apps/sim/tools/gong/create_call.ts +++ b/apps/sim/tools/gong/create_call.ts @@ -1,5 +1,5 @@ import type { GongCreateCallParams, GongCreateCallResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongJsonArray } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongJsonArray } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -103,7 +103,7 @@ export const createCallTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const body: Record = { diff --git a/apps/sim/tools/gong/day_by_day_activity.ts b/apps/sim/tools/gong/day_by_day_activity.ts index 3d92ced27ec..d7edf5be0b4 100644 --- a/apps/sim/tools/gong/day_by_day_activity.ts +++ b/apps/sim/tools/gong/day_by_day_activity.ts @@ -1,5 +1,5 @@ import type { GongDayByDayActivityParams, GongDayByDayActivityResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -59,7 +59,7 @@ export const dayByDayActivityTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = { diff --git a/apps/sim/tools/gong/get_brief.ts b/apps/sim/tools/gong/get_brief.ts index 4379c69fdb9..dc075c2e7c6 100644 --- a/apps/sim/tools/gong/get_brief.ts +++ b/apps/sim/tools/gong/get_brief.ts @@ -1,5 +1,5 @@ import type { GongGetBriefParams, GongGetBriefResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -94,7 +94,7 @@ export const getBriefTool: ToolConfig }, method: 'GET', headers: (params) => ({ - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/get_call.ts b/apps/sim/tools/gong/get_call.ts index 57c558139a4..6170b15d5fb 100644 --- a/apps/sim/tools/gong/get_call.ts +++ b/apps/sim/tools/gong/get_call.ts @@ -1,5 +1,5 @@ import type { GongGetCallParams, GongGetCallResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -36,7 +36,7 @@ export const getCallTool: ToolConfig = { method: 'GET', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/get_call_transcript.ts b/apps/sim/tools/gong/get_call_transcript.ts index a36a1069c81..94cf6e1cf97 100644 --- a/apps/sim/tools/gong/get_call_transcript.ts +++ b/apps/sim/tools/gong/get_call_transcript.ts @@ -1,5 +1,5 @@ import type { GongGetCallTranscriptParams, GongGetCallTranscriptResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -63,7 +63,7 @@ export const getCallTranscriptTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = {} diff --git a/apps/sim/tools/gong/get_coaching.ts b/apps/sim/tools/gong/get_coaching.ts index 8c8480415b5..d1328553065 100644 --- a/apps/sim/tools/gong/get_coaching.ts +++ b/apps/sim/tools/gong/get_coaching.ts @@ -5,7 +5,7 @@ import type { GongGetCoachingParams, GongGetCoachingResponse, } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -67,7 +67,7 @@ export const getCoachingTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/get_extensive_calls.ts b/apps/sim/tools/gong/get_extensive_calls.ts index 92d2422f3d0..710339bf5e7 100644 --- a/apps/sim/tools/gong/get_extensive_calls.ts +++ b/apps/sim/tools/gong/get_extensive_calls.ts @@ -1,5 +1,5 @@ import type { GongGetExtensiveCallsParams, GongGetExtensiveCallsResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -70,7 +70,7 @@ export const getExtensiveCallsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = {} diff --git a/apps/sim/tools/gong/get_folder_content.ts b/apps/sim/tools/gong/get_folder_content.ts index 5b22d1ea332..16b8ffaef0c 100644 --- a/apps/sim/tools/gong/get_folder_content.ts +++ b/apps/sim/tools/gong/get_folder_content.ts @@ -1,5 +1,5 @@ import type { GongGetFolderContentParams, GongGetFolderContentResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -43,7 +43,7 @@ export const getFolderContentTool: ToolConfig< method: 'GET', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/get_logs.ts b/apps/sim/tools/gong/get_logs.ts index 786ee2aa47c..3a33e0ecf3d 100644 --- a/apps/sim/tools/gong/get_logs.ts +++ b/apps/sim/tools/gong/get_logs.ts @@ -1,5 +1,5 @@ import type { GongGetLogsParams, GongGetLogsResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -63,7 +63,7 @@ export const getLogsTool: ToolConfig = { }, method: 'GET', headers: (params) => ({ - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/get_prospect_flows.ts b/apps/sim/tools/gong/get_prospect_flows.ts index e4563fea25d..5ef31e37d51 100644 --- a/apps/sim/tools/gong/get_prospect_flows.ts +++ b/apps/sim/tools/gong/get_prospect_flows.ts @@ -1,5 +1,5 @@ import type { GongGetProspectFlowsParams, GongGetProspectFlowsResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -39,7 +39,7 @@ export const getProspectFlowsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => ({ crmProspectsIds: parseGongIdList(params.crmProspectsIds) ?? [], diff --git a/apps/sim/tools/gong/get_user.ts b/apps/sim/tools/gong/get_user.ts index 51dc55c1cb3..d65747ca690 100644 --- a/apps/sim/tools/gong/get_user.ts +++ b/apps/sim/tools/gong/get_user.ts @@ -1,5 +1,5 @@ import type { GongGetUserParams, GongGetUserResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -36,7 +36,7 @@ export const getUserTool: ToolConfig = { method: 'GET', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/interaction_stats.ts b/apps/sim/tools/gong/interaction_stats.ts index c6dc2f920b4..a549b63ab6e 100644 --- a/apps/sim/tools/gong/interaction_stats.ts +++ b/apps/sim/tools/gong/interaction_stats.ts @@ -1,5 +1,5 @@ import type { GongInteractionStatsParams, GongInteractionStatsResponse } from '@/tools/gong/types' -import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization, parseGongIdList } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -59,7 +59,7 @@ export const interactionStatsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const filter: Record = { diff --git a/apps/sim/tools/gong/list_calls.ts b/apps/sim/tools/gong/list_calls.ts index a50487f61a8..6bcef5a6382 100644 --- a/apps/sim/tools/gong/list_calls.ts +++ b/apps/sim/tools/gong/list_calls.ts @@ -1,5 +1,5 @@ import type { GongListCallsParams, GongListCallsResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -62,7 +62,7 @@ export const listCallsTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_flows.ts b/apps/sim/tools/gong/list_flows.ts index 327c371db67..69b0079af3b 100644 --- a/apps/sim/tools/gong/list_flows.ts +++ b/apps/sim/tools/gong/list_flows.ts @@ -1,5 +1,5 @@ import type { GongListFlowsParams, GongListFlowsResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -56,7 +56,7 @@ export const listFlowsTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_library_folders.ts b/apps/sim/tools/gong/list_library_folders.ts index af4eecfed84..c77f9fdd7dc 100644 --- a/apps/sim/tools/gong/list_library_folders.ts +++ b/apps/sim/tools/gong/list_library_folders.ts @@ -2,7 +2,7 @@ import type { GongListLibraryFoldersParams, GongListLibraryFoldersResponse, } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -46,7 +46,7 @@ export const listLibraryFoldersTool: ToolConfig< method: 'GET', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_scorecards.ts b/apps/sim/tools/gong/list_scorecards.ts index c2081e4e805..950303417f3 100644 --- a/apps/sim/tools/gong/list_scorecards.ts +++ b/apps/sim/tools/gong/list_scorecards.ts @@ -1,5 +1,5 @@ import type { GongListScorecardsParams, GongListScorecardsResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -31,7 +31,7 @@ export const listScorecardsTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_trackers.ts b/apps/sim/tools/gong/list_trackers.ts index 214b8545938..ffe03bdb711 100644 --- a/apps/sim/tools/gong/list_trackers.ts +++ b/apps/sim/tools/gong/list_trackers.ts @@ -1,5 +1,5 @@ import type { GongListTrackersParams, GongListTrackersResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -41,7 +41,7 @@ export const listTrackersTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_users.ts b/apps/sim/tools/gong/list_users.ts index 706e0386337..2e95915705e 100644 --- a/apps/sim/tools/gong/list_users.ts +++ b/apps/sim/tools/gong/list_users.ts @@ -1,5 +1,5 @@ import type { GongListUsersParams, GongListUsersResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -49,7 +49,7 @@ export const listUsersTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/list_workspaces.ts b/apps/sim/tools/gong/list_workspaces.ts index b7ad3805a32..e9b72233352 100644 --- a/apps/sim/tools/gong/list_workspaces.ts +++ b/apps/sim/tools/gong/list_workspaces.ts @@ -1,5 +1,5 @@ import type { GongListWorkspacesParams, GongListWorkspacesResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -31,7 +31,7 @@ export const listWorkspacesTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/lookup_email.ts b/apps/sim/tools/gong/lookup_email.ts index ac409814c62..2fdfac701c2 100644 --- a/apps/sim/tools/gong/lookup_email.ts +++ b/apps/sim/tools/gong/lookup_email.ts @@ -1,5 +1,5 @@ import type { GongLookupEmailParams, GongLookupEmailResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -41,7 +41,7 @@ export const lookupEmailTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/lookup_phone.ts b/apps/sim/tools/gong/lookup_phone.ts index 32506758f83..c9c391fbc40 100644 --- a/apps/sim/tools/gong/lookup_phone.ts +++ b/apps/sim/tools/gong/lookup_phone.ts @@ -1,5 +1,5 @@ import type { GongLookupPhoneParams, GongLookupPhoneResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -41,7 +41,7 @@ export const lookupPhoneTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/purge_email_address.ts b/apps/sim/tools/gong/purge_email_address.ts index bd9030925ce..28e57016460 100644 --- a/apps/sim/tools/gong/purge_email_address.ts +++ b/apps/sim/tools/gong/purge_email_address.ts @@ -1,5 +1,5 @@ import type { GongPurgeEmailAddressParams, GongPurgeEmailAddressResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -48,7 +48,7 @@ export const purgeEmailAddressTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/purge_phone_number.ts b/apps/sim/tools/gong/purge_phone_number.ts index a064f98b6b0..d73cfbd26c0 100644 --- a/apps/sim/tools/gong/purge_phone_number.ts +++ b/apps/sim/tools/gong/purge_phone_number.ts @@ -1,5 +1,5 @@ import type { GongPurgePhoneNumberParams, GongPurgePhoneNumberResponse } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -49,7 +49,7 @@ export const purgePhoneNumberTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), }, diff --git a/apps/sim/tools/gong/unassign_flow_prospects.ts b/apps/sim/tools/gong/unassign_flow_prospects.ts index fa04e49d464..048ddd8ae23 100644 --- a/apps/sim/tools/gong/unassign_flow_prospects.ts +++ b/apps/sim/tools/gong/unassign_flow_prospects.ts @@ -2,7 +2,7 @@ import type { GongUnassignFlowProspectsParams, GongUnassignFlowProspectsResponse, } from '@/tools/gong/types' -import { getGongErrorMessage } from '@/tools/gong/utils' +import { getGongErrorMessage, gongAuthorization } from '@/tools/gong/utils' import { GONG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -56,7 +56,7 @@ export const unassignFlowProspectsTool: ToolConfig< method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', - Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + Authorization: gongAuthorization(params.accessKey, params.accessKeySecret), }), body: (params) => { const body: Record = { diff --git a/apps/sim/tools/gong/utils.ts b/apps/sim/tools/gong/utils.ts index 06c725e5504..5edecf67cca 100644 --- a/apps/sim/tools/gong/utils.ts +++ b/apps/sim/tools/gong/utils.ts @@ -1,3 +1,11 @@ +/** + * The Basic `Authorization` value Gong expects: the access key and its secret joined by a colon, + * Base64-encoded. Throws where `btoa` does, for a pair that is not Latin-1. + */ +export function gongAuthorization(accessKey: string, accessKeySecret: string): string { + return `Basic ${btoa(`${accessKey}:${accessKeySecret}`)}` +} + /** * Extract a useful Gong API error message from documented error payloads. */ From c05e7e44779c498b8f22218fec1f8e1f72cea26a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:04:48 -0800 Subject: [PATCH 46/83] fix(credentials): stop accepting an undocumented PlanetScale 403 as proof of a service token PlanetScale documents 401 for a bad token and 404 for missing access, but gives no meaning to a 403 from listing organizations, so a JSON 403 now rejects like any other refusal. The token ID is checked in the account-id normalizer with the same printable, colon-free rule the tools' header builder applies. --- .../validators/token-checks.ts | 33 ++++++++++++------- apps/sim/tools/planetscale/utils.ts | 16 +++++++-- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index aa4f7ba9ef9..5029b0b689e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -28,6 +28,7 @@ import { convexApiUrl, convexAuthHeaders } from '@/tools/convex/utils' import { buildDynatraceUrl, dynatraceHeaders } from '@/tools/dynatrace/utils' import { normalizeEmailBisonBaseUrl } from '@/tools/emailbison/utils' import { normalizeGitLabHost } from '@/tools/gitlab/utils' +import { gongAuthorization } from '@/tools/gong/utils' import { GRANOLA_API_BASE, granolaHeaders } from '@/tools/granola/utils' import { buildJotformHeaders, buildJotformUrl, JOTFORM_REGION_HOSTS } from '@/tools/jotform/utils' import { @@ -40,7 +41,11 @@ import { getMailgunApiBaseUrl } from '@/tools/mailgun/utils' import { getNerdGraphEndpoint, newRelicHeaders } from '@/tools/new_relic/utils' import { oktaHeaders } from '@/tools/okta/utils' import { planeApiUrl, planeHeaders } from '@/tools/plane/utils' -import { PLANETSCALE_API_ORIGIN, planetScaleHeaders } from '@/tools/planetscale/utils' +import { + isPlanetScaleTokenPart, + PLANETSCALE_API_ORIGIN, + planetScaleHeaders, +} from '@/tools/planetscale/utils' import { createBasicAuthHeader } from '@/tools/servicenow/utils' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' @@ -688,10 +693,10 @@ export const validateGitLabServiceAccount = tokenCheckValidator({ /** * Listing workspaces is a cheap documented read on the fixed host the tools call, with the same - * Basic `accessKey:accessKeySecret` the tools encode with `btoa`. Gong documents a 403 ("Access - * denied") for this read without telling a bad key from a missing permission, so any refusal - * rejects. The access key is the Basic username, so one with a colon is refused unsent, as is a - * pair `btoa` cannot encode. + * Basic header the tools send (`gongAuthorization`). Gong documents a 403 ("Access denied") for + * this read without telling a bad key from a missing permission, so any refusal rejects. The + * access key is the Basic username, so one with a colon is refused unsent, as is a pair the + * header cannot encode. */ export const validateGongServiceAccount = tokenCheckValidator({ serviceLabel: 'Gong', @@ -701,9 +706,9 @@ export const validateGongServiceAccount = tokenCheckValidator({ return accessKey }, request: ({ apiToken, providerWorkspaceId = '' }) => { - let credentials: string + let authorization: string try { - credentials = btoa(`${providerWorkspaceId}:${apiToken}`) + authorization = gongAuthorization(providerWorkspaceId, apiToken) } catch { throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify', @@ -712,7 +717,7 @@ export const validateGongServiceAccount = tokenCheckValidator({ } return { url: 'https://api.gong.io/v2/workspaces', - headers: { Authorization: `Basic ${credentials}`, Accept: 'application/json' }, + headers: { Authorization: authorization, Accept: 'application/json' }, } }, }) @@ -1171,13 +1176,17 @@ export const validatePlaneServiceAccount = tokenCheckValidator({ /** * Listing the organizations a service token can reach is the read PlanetScale's own docs use, sent * with the `:` Authorization the tools build (`planetScaleHeaders`). A bad ID or - * token gets 401; a JSON 403 is the vendor refusing a valid token this read. A pair the tools' - * header builder refuses (a colon or whitespace would split it) is refused unsent. + * token gets 401. PlanetScale documents no meaning for a 403 here, so it rejects like any other + * refusal. An ID or token the tools' header builder refuses (a colon or whitespace would split + * the pair) is refused unsent. */ export const validatePlanetScaleServiceAccount = tokenCheckValidator({ serviceLabel: 'PlanetScale', - workspaceId: ({ providerWorkspaceId }) => (providerWorkspaceId ?? '').trim(), - forbiddenMeansScoped: true, + workspaceId: ({ providerWorkspaceId }) => { + const tokenId = (providerWorkspaceId ?? '').trim() + if (!isPlanetScaleTokenPart(tokenId)) throw new Error('invalid service token ID') + return tokenId + }, request: ({ apiToken, providerWorkspaceId = '' }) => { let headers: Record try { diff --git a/apps/sim/tools/planetscale/utils.ts b/apps/sim/tools/planetscale/utils.ts index ae2e4dad6ef..26ea5f7225c 100644 --- a/apps/sim/tools/planetscale/utils.ts +++ b/apps/sim/tools/planetscale/utils.ts @@ -180,10 +180,20 @@ export const planetScalePaginationSchema = z totalPages: data.total_pages, })) +/** + * Whether a value can be one half of the `:` Authorization PlanetScale reads: + * printable ASCII with no whitespace and no colon, which would split the pair. + */ +export function isPlanetScaleTokenPart(value: unknown): value is string { + return typeof value === 'string' && /^[\x21-\x39\x3b-\x7e]+$/.test(value) +} + export function planetScaleHeaders(credentials: PlanetScaleCredentials): Record { - for (const value of [credentials.serviceTokenId, credentials.serviceToken]) { - if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value) || value.includes(':')) - throw new Error('Invalid PlanetScale service token credentials') + if ( + !isPlanetScaleTokenPart(credentials.serviceTokenId) || + !isPlanetScaleTokenPart(credentials.serviceToken) + ) { + throw new Error('Invalid PlanetScale service token credentials') } return { Authorization: `${credentials.serviceTokenId}:${credentials.serviceToken}`, From 119515dbbdbda3055a97bb0c7a112172b9d1f0e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:04:49 -0800 Subject: [PATCH 47/83] test(credentials): prove refused saved-credential hosts never reach the guarded egress fetch The steer cases asserted only on the global fetch stub, but ServiceNow sends through the guarded egress transport, so its 'before sending the key' half never ran. Both transports now answer, so only the refusal can keep the key unsent. --- .../validators/token-checks.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 5bd4d974849..8cc136e1823 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -370,13 +370,23 @@ describe('saved credentials whose connection names a host, region, or account', ])( '%s refuses a typed value that could steer the request, before sending the key', async (_, validate, typed) => { - const fetch = vi.fn() + // Both transports would answer, so only the refusal itself can keep the key unsent. + const fetch = vi.fn().mockResolvedValue(jsonResponse({}, 401)) vi.stubGlobal('fetch', fetch) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'instance.example.com', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockReset() + .mockResolvedValue(jsonResponse({}, 401)) await expect(validate({ apiToken: 'secret', ...typed })).rejects.toMatchObject({ code: 'invalid_credentials', }) expect(fetch).not.toHaveBeenCalled() + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() } ) @@ -515,15 +525,15 @@ describe('PlanetScale saved service token verification', () => { ).rejects.toMatchObject({ code: 'invalid_credentials' }) }) - it('accepts a valid token that PlanetScale forbids from this read', async () => { + it('rejects a JSON 403, which PlanetScale does not document as a valid token', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(jsonResponse({ code: 'forbidden', message: 'Forbidden' }, 403)) ) await expect( - validatePlanetScaleServiceAccount({ apiToken: 'scoped', providerWorkspaceId: 'tok-id' }) - ).resolves.toMatchObject({ principal: null }) + validatePlanetScaleServiceAccount({ apiToken: 'any', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) }) it('does not take a proxy 403 page as proof of the token', async () => { From d0cb2567de2ac91775effcf8bb4386c255ba9799 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:04:50 -0800 Subject: [PATCH 48/83] fix(credentials): tell ServiceNow and Gong users which hosts the saved credential supports ServiceNow custom URLs are aliases of the instance's original service-now.com address, so the help text points custom-URL tenants there; on-premises instances are named as unsupported. Gong's help text names the fixed api.gong.io host the tools call. --- .../lib/credentials/token-service-accounts/descriptors.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 3226df93a76..d1de0c3b8a1 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -2392,9 +2392,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/gong', helpText: - 'A Gong technical administrator creates the access key and secret on the API page of the Gong company settings.', + 'A Gong technical administrator creates the access key and secret on the API page of the Gong company settings. Sim calls https://api.gong.io; an account whose API page shows a different base URL is not supported yet.', invalidCredentialsHelp: - 'Gong rejected this access key and secret. Check that they were created together, have not been revoked, and that your Gong plan includes API access.', + 'Gong rejected this access key and secret. Check that they were created together, have not been revoked, and that your Gong plan includes API access. If the API page in Gong shows a base URL other than https://api.gong.io, that account is not supported yet.', }, [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2423,9 +2423,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/servicenow', helpText: - 'Use a dedicated integration user with the roles your workflows need and without multi-factor authentication. Enter the instance host on service-now.com, servicenow.com, or servicenowservices.com; custom domains are not supported.', + "Use a dedicated integration user with the roles your workflows need and without multi-factor authentication. Enter the instance host on service-now.com, servicenow.com, or servicenowservices.com. If your instance uses a custom URL, enter the instance's original service-now.com address instead; custom URLs and on-premises instances are not supported.", invalidCredentialsHelp: - 'ServiceNow rejected this username and password. Check them on the instance you entered, and that the user is active, not locked out, and not required to use multi-factor authentication.', + "ServiceNow rejected this username and password, or the instance URL is not a ServiceNow address. Enter the instance's service-now.com address (not a custom URL), and check that the user is active, not locked out, and not required to use multi-factor authentication.", }, } From 90c113e2414f2ec44fe5180bc9610162442b92c3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:17:14 -0800 Subject: [PATCH 49/83] docs(credentials): document the in-band Twilio API key form and hint a half-entered key pair The saved Twilio credential keeps an API key in the auth-token field as SKxxx:secret, Twilio's documented Basic username and password for a key. The provider id now records that choice and warns that a consumer needing the account auth token itself, such as webhook signature validation, must refuse a key pair. The connect form shows a hint while an SK value is not a complete SID:secret pair. --- .../credentials/token-service-accounts/descriptors.ts | 10 ++++++++++ apps/sim/tools/twilio/utils.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index d1de0c3b8a1..1ec3ff71399 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -102,6 +102,14 @@ export const DEVIN_SERVICE_ACCOUNT_PROVIDER_ID = 'devin-service-account' as cons export const EMAILBISON_SERVICE_ACCOUNT_PROVIDER_ID = 'emailbison-service-account' as const export const UPSTASH_SERVICE_ACCOUNT_PROVIDER_ID = 'upstash-service-account' as const export const ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID = 'algolia-service-account' as const +/** + * The Twilio credential's `apiToken` holds either the account auth token or an API key pair + * (`SKxxx:secret`), deliberately in one field: the pair is exactly the Basic username and password + * Twilio takes for a key, so every surface (saved credential, inline block field, API) carries it + * with no extra field. Build request auth only with `twilioAuthorization`; a consumer that needs + * the account auth token itself (webhook signature HMAC) must refuse a value `isTwilioApiKeyPair` + * matches, since an API key secret cannot sign. + */ export const TWILIO_SERVICE_ACCOUNT_PROVIDER_ID = 'twilio-service-account' as const export const AZURE_DEVOPS_SERVICE_ACCOUNT_PROVIDER_ID = 'azure-devops-service-account' as const export const PLANE_SERVICE_ACCOUNT_PROVIDER_ID = 'plane-service-account' as const @@ -2145,6 +2153,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< label: 'Auth token or API key', placeholder: 'Paste your auth token, or SKxxx:secret for an API key', secret: true, + hintPattern: /^(?!SK)|^SK[0-9a-fA-F]{32}:\S+$/, + hintMessage: 'Enter an API key as its SID and secret joined by a colon: SKxxx:secret.', }, { id: 'providerWorkspaceId', diff --git a/apps/sim/tools/twilio/utils.ts b/apps/sim/tools/twilio/utils.ts index 713d650250d..74d1126cea4 100644 --- a/apps/sim/tools/twilio/utils.ts +++ b/apps/sim/tools/twilio/utils.ts @@ -1,7 +1,8 @@ /** * A Twilio API key and its secret as one value, `SK<32 hex>:`: the exact username and - * password Twilio's HTTP Basic auth takes for an API key. An auth token is 32 hex characters - * and never contains a colon, so the two can't be confused. + * password Twilio's HTTP Basic auth takes for an API key. Twilio documents a key SID as `SK` and + * 32 hex characters; the auth tokens it issues are 32 hex characters with no colon (the Account + * resource documents no pattern), so no issued auth token takes this shape. */ const TWILIO_API_KEY_PAIR = /^SK[0-9a-fA-F]{32}:\S+$/ From 784729c71ec201c2dd2acdaa592e0484cefe3d37 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:20:49 -0800 Subject: [PATCH 50/83] fix(credentials): share the CrowdStrike token exchange and report a wrong Falcon cloud - Legacy inline tokens are also dropped on a 403, so a scope granted after a failed run applies on the next one, as it did before tokens were cached. - The saved-credential minter and the legacy path call one exchange, requestCrowdStrikeToken; the legacy path keeps following redirects. - The minter never follows a redirect and reports one as invalid_credentials naming the API client's cloud from X-Cs-Region, instead of a provider outage. - Drop the unused instanceUrl param and cap a saved-credential accessToken. --- .../lib/api/contracts/tools/crowdstrike.ts | 2 +- .../minters/crowdstrike.test.ts | 45 +++++++- .../minters/crowdstrike.ts | 96 +++++++--------- apps/sim/lib/internal/crowdstrike/client.ts | 108 ++++++++++++++---- .../internal/crowdstrike/operations.test.ts | 24 ++++ .../lib/internal/crowdstrike/operations.ts | 9 +- apps/sim/tools/crowdstrike/types.ts | 3 +- 7 files changed, 199 insertions(+), 88 deletions(-) diff --git a/apps/sim/lib/api/contracts/tools/crowdstrike.ts b/apps/sim/lib/api/contracts/tools/crowdstrike.ts index 1a2d9e21cd2..0d88f6d5f04 100644 --- a/apps/sim/lib/api/contracts/tools/crowdstrike.ts +++ b/apps/sim/lib/api/contracts/tools/crowdstrike.ts @@ -110,7 +110,7 @@ const crowdstrikeAggregatesResponseSchema = z.object({ const baseRequestSchema = z.object({ clientId: z.string().min(1, 'Client ID is required').optional(), clientSecret: z.string().min(1, 'Client Secret is required').optional(), - accessToken: z.string().min(1).optional(), + accessToken: z.string().min(1).max(16_384).optional(), cloud: z.enum(CROWDSTRIKE_CLOUDS), }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts index 0c80d05c9cf..8a6b2bbf57a 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts @@ -17,7 +17,7 @@ describe('CrowdStrike API client token exchange', () => { vi.stubGlobal('fetch', mockFetch) }) - it("posts the client credentials to the selected cloud's token endpoint and never follows a redirect", async () => { + it("posts the client credentials to the selected cloud's token endpoint without following redirects", async () => { const requests: Request[] = [] mockFetch.mockImplementation(async (input, init) => { const request = new Request(input, init) @@ -31,7 +31,7 @@ describe('CrowdStrike API client token exchange', () => { const [request] = requests expect(request.url).toBe('https://api.eu-1.crowdstrike.com/oauth2/token') expect(request.method).toBe('POST') - expect(request.redirect).toBe('error') + expect(request.redirect).toBe('manual') expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') expect(Object.fromEntries(new URLSearchParams(await request.text()))).toEqual({ grant_type: 'client_credentials', @@ -84,8 +84,45 @@ describe('CrowdStrike API client token exchange', () => { await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) }) - it('treats a redirect from the token endpoint as a provider failure, not a followed hop', async () => { - mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')) + it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => { + mockFetch.mockResolvedValue( + new Response(null, { + status: 308, + headers: { + Location: 'https://api.us-2.crowdstrike.com/oauth2/token', + 'X-Cs-Region': 'us-2', + }, + }) + ) + + const error = await mintCrowdStrikeServiceAccountToken(FIELDS).catch((caught) => caught) + + expect(error).toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: { cloud: 'eu-1', apiClientCloud: 'us-2' }, + }) + expect(JSON.stringify(error.logDetail)).not.toContain('https://') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('reports any other redirect from the token endpoint as a wrong cloud without naming one', async () => { + mockFetch.mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: 'https://evil.example/oauth2/token', 'X-Cs-Region': 'evil.example' }, + }) + ) + + const error = await mintCrowdStrikeServiceAccountToken(FIELDS).catch((caught) => caught) + + expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 }) + expect(JSON.stringify(error.logDetail)).not.toContain('evil.example') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('treats a network failure reaching the token endpoint as a provider failure', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')) await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code: 'provider_unavailable', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts index b477765e0e9..6bf77c4c8fb 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts @@ -1,5 +1,3 @@ -import { toRecord } from '@sim/utils/object' -import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import type { ClientCredentialAccountFields, ClientCredentialAccountMintOptions, @@ -7,19 +5,20 @@ import type { } from '@/lib/credentials/client-credential-accounts/server' import { tenantPrincipal } from '@/lib/credentials/principal' import { - fetchProvider, + providerFailureCode, + providerFailureReason, requireClientSecret, TokenServiceAccountValidationError, - throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import { - getCloudBaseUrl, - getTokenLifetimeSeconds, + CrowdStrikeAuthError, + CrowdStrikeTokenRedirectError, isCrowdStrikeCloud, + requestCrowdStrikeToken, } from '@/lib/internal/crowdstrike/client' const STEP = 'crowdstrike_token_mint' -const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000 const MAX_MEMBER_CID_LENGTH = 128 const FORBIDDEN_FIELD_TEXT = /[\u0000-\u001f\u007f\s]/ @@ -28,12 +27,13 @@ function invalidCredentials(reason: string): TokenServiceAccountValidationError } /** - * Mints a Falcon API access token with the API client's id and secret: - * `POST {cloud API origin}/oauth2/token` (form fields `client_id`, - * `client_secret`, and, for an MSSP parent acting on one child, `member_cid`). - * The cloud comes only from the Falcon cloud allowlist, and the request never - * follows a redirect, so the secret reaches no other host. Tokens last 30 - * minutes; the credential cache re-mints before they lapse. + * Mints a Falcon API access token with the API client's id and secret through + * the token exchange the legacy block shares. The cloud comes only from the + * Falcon cloud allowlist, and the exchange never follows a redirect, so the + * secret reaches no other host. Falcon redirects a token request sent to the + * wrong cloud, so a redirect is the user's cloud choice being wrong, not an + * outage. Tokens last 30 minutes; the credential cache re-mints before they + * lapse. * * Falcon exposes no identity for the API client in the token response, so the * connect-time check is the mint itself. @@ -56,56 +56,44 @@ export async function mintCrowdStrikeServiceAccountToken( throw invalidCredentials('member CID is invalid') } - const baseUrl = getCloudBaseUrl(cloud) - const body = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: fields.clientId, - client_secret: clientSecret, - }) - if (memberCid) body.set('member_cid', memberCid) - - const response = await fetchProvider( - `${baseUrl}/oauth2/token`, - { - method: 'POST', - redirect: 'error', - signal: options?.signal, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: body.toString(), - }, - STEP - ) - await throwForProviderResponse(response, STEP, { cloud }) - - let token: Record + const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS) + let minted: { token: string; expiresInSeconds: number } try { - token = toRecord( - await readResponseJsonWithLimit(response, { - maxBytes: MAX_TOKEN_RESPONSE_BYTES, - label: 'CrowdStrike token response', - signal: options?.signal, - }) + minted = await requestCrowdStrikeToken( + { clientId: fields.clientId, clientSecret, cloud, memberCid: memberCid || undefined }, + { + followRedirects: false, + signal: options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout, + } ) - } catch { + } catch (error) { options?.signal?.throwIfAborted() + if (error instanceof CrowdStrikeTokenRedirectError) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: error.region + ? 'the API client belongs to another Falcon cloud' + : 'token endpoint answered with a redirect', + cloud, + ...(error.region ? { apiClientCloud: error.region } : {}), + }) + } + if (error instanceof CrowdStrikeAuthError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.status), + error.status, + { step: STEP, cloud } + ) + } throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: STEP, - reason: 'provider returned an invalid or oversized response', - }) - } - if (typeof token.access_token !== 'string' || !token.access_token.trim()) { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: STEP, - reason: 'token response missing access_token', + reason: providerFailureReason(error), }) } return { - accessToken: token.access_token, - expiresInSeconds: getTokenLifetimeSeconds(token.expires_in), + accessToken: minted.token, + expiresInSeconds: minted.expiresInSeconds, domain: cloud, identity: { displayName: memberCid ? `CrowdStrike ${memberCid}` : `CrowdStrike ${cloud.toUpperCase()}`, diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index a14d31fbdd4..60563a7e293 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -169,10 +169,11 @@ export class CrowdStrikeAuthError extends Error { } } -async function readFalconJson(response: Response): Promise { +async function readFalconJson(response: Response, signal?: AbortSignal): Promise { const text = await readResponseTextWithLimit(response, { maxBytes: MAX_JSON_API_RESPONSE_BYTES, label: 'CrowdStrike response body', + signal, }) try { @@ -187,7 +188,7 @@ const CROWDSTRIKE_TOKEN_LIFETIME_SECONDS = 30 * 60 const INLINE_TOKEN_EXPIRY_BUFFER_MS = 60_000 /** A token response's `expires_in`, or Falcon's standard 30-minute lifetime when it is unusable. */ -export function getTokenLifetimeSeconds(expiresIn: unknown): number { +function getTokenLifetimeSeconds(expiresIn: unknown): number { return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : CROWDSTRIKE_TOKEN_LIFETIME_SECONDS @@ -214,32 +215,74 @@ export function invalidateAccessToken(params: CrowdStrikeInlineCredentials): voi inlineTokenCache.delete(inlineTokenCacheKey(params)) } -export async function getAccessToken( - params: CrowdStrikeInlineCredentials, - signal?: AbortSignal -): Promise { +/** API client credentials for one Falcon token request. */ +export interface CrowdStrikeTokenRequest extends CrowdStrikeInlineCredentials { + /** The child CID an MSSP parent's token is locked to. */ + memberCid?: string +} + +/** + * Raised when a token request that does not follow redirects is answered with + * one. Falcon redirects a token request sent to a cloud other than the API + * client's own and names that cloud in `X-Cs-Region`; `region` carries it only + * when it is one of the Falcon clouds Sim knows. + */ +export class CrowdStrikeTokenRedirectError extends Error { + constructor( + readonly status: number, + readonly region: CrowdStrikeCloud | null + ) { + super('CrowdStrike token endpoint answered with a redirect') + this.name = 'CrowdStrikeTokenRedirectError' + } +} + +/** + * Exchanges API client credentials for a Falcon access token: + * `POST {cloud API origin}/oauth2/token` with `client_id`, `client_secret`, + * and, for an MSSP parent acting on one child, `member_cid`, as form fields. + * With `followRedirects: false` a redirect is never followed and throws + * {@link CrowdStrikeTokenRedirectError}, so the secret reaches no other host; + * the legacy block's inline path keeps following one as it always has. A + * refusal throws {@link CrowdStrikeAuthError} with Falcon's status. + * + * @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md + */ +export async function requestCrowdStrikeToken( + request: CrowdStrikeTokenRequest, + options: { followRedirects: boolean; signal?: AbortSignal } +): Promise<{ token: string; expiresInSeconds: number }> { + const { followRedirects, signal } = options signal?.throwIfAborted() - const cacheKey = inlineTokenCacheKey(params) - const cached = inlineTokenCache.get(cacheKey) - if (cached) return cached + const body = new URLSearchParams({ + client_id: request.clientId, + client_secret: request.clientSecret, + grant_type: 'client_credentials', + }) + if (request.memberCid) body.set('member_cid', request.memberCid) - const baseUrl = getCloudBaseUrl(params.cloud) - const response = await fetch(`${baseUrl}/oauth2/token`, { + const response = await fetch(`${getCloudBaseUrl(request.cloud)}/oauth2/token`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, - body: new URLSearchParams({ - client_id: params.clientId, - client_secret: params.clientSecret, - grant_type: 'client_credentials', - }).toString(), + body: body.toString(), cache: 'no-store', + redirect: followRedirects ? 'follow' : 'manual', signal, }) - const data = await readFalconJson(response) + if (!followRedirects && response.status >= 300 && response.status < 400) { + await response.body?.cancel().catch(() => {}) + const region = response.headers.get('x-cs-region')?.trim().toLowerCase() + throw new CrowdStrikeTokenRedirectError( + response.status, + isCrowdStrikeCloud(region) ? region : null + ) + } + + const data = await readFalconJson(response, signal) signal?.throwIfAborted() if (!response.ok) { throw new CrowdStrikeAuthError( @@ -247,16 +290,33 @@ export async function getAccessToken( response.status ) } - - if (!isRecordLike(data) || typeof data.access_token !== 'string') { + if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token.trim()) { throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502) } - const ttl = Math.floor( - getTokenLifetimeSeconds(data.expires_in) * 1000 - INLINE_TOKEN_EXPIRY_BUFFER_MS - ) - if (ttl > 0) inlineTokenCache.set(cacheKey, data.access_token, { ttl }) - return data.access_token + return { + token: data.access_token, + expiresInSeconds: getTokenLifetimeSeconds(data.expires_in), + } +} + +/** A token for inline (legacy block) credentials, reused from the cache until shortly before it lapses. */ +export async function getAccessToken( + params: CrowdStrikeInlineCredentials, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const cacheKey = inlineTokenCacheKey(params) + const cached = inlineTokenCache.get(cacheKey) + if (cached) return cached + + const { token, expiresInSeconds } = await requestCrowdStrikeToken(params, { + followRedirects: true, + signal, + }) + const ttl = Math.floor(expiresInSeconds * 1000 - INLINE_TOKEN_EXPIRY_BUFFER_MS) + if (ttl > 0) inlineTokenCache.set(cacheKey, token, { ttl }) + return token } interface CrowdStrikeRequestOptions { diff --git a/apps/sim/lib/internal/crowdstrike/operations.test.ts b/apps/sim/lib/internal/crowdstrike/operations.test.ts index d4894333e69..c4f7b074dc4 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.test.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.test.ts @@ -116,6 +116,30 @@ describe('CrowdStrike operations', () => { expect(tokenUrls).toHaveLength(2) }) + it('mints a new inline token after Falcon refuses one with 403, so a newly granted scope applies', async () => { + const body = { + operation: 'crowdstrike_query_sensors' as const, + clientId: 'scoped-client', + clientSecret: 'scoped-secret', + cloud: 'us-1' as const, + } + let minted = 0 + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + if (url.endsWith('/oauth2/token')) { + minted += 1 + return jsonResponse({ access_token: `token-${minted}`, expires_in: 1799 }) + } + const auth = (init.headers as Record).Authorization + return auth === 'Bearer token-1' + ? jsonResponse({ errors: [{ code: 403, message: 'access denied' }] }, 403) + : jsonResponse({ resources: [] }) + }) + + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: false, status: 403 }) + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + expect(minted).toBe(2) + }) + it('never reuses an inline token for a different secret or cloud', async () => { fetchMock.mockImplementation(async (url: string) => url.endsWith('/oauth2/token') diff --git a/apps/sim/lib/internal/crowdstrike/operations.ts b/apps/sim/lib/internal/crowdstrike/operations.ts index 7caf4db4beb..27c2f6ed794 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.ts @@ -1013,8 +1013,9 @@ function inlineCredentials(body: CrowdstrikeQueryBody): CrowdStrikeInlineCredent /** * Runs one CrowdStrike operation against `body.cloud`. A token minted from a * saved credential (`accessToken`) is used as is; inline credentials mint a - * token, or reuse one cached for them, and a 401 drops that cached token so the - * next request mints again. + * token, or reuse one cached for them. A 401 or 403 drops that cached token so + * the next request mints again: a rejected token, or one minted before the API + * client was granted a scope, is never reused. */ export async function executeCrowdStrikeRequest( body: CrowdstrikeQueryBody, @@ -1041,6 +1042,8 @@ export async function executeCrowdStrikeRequest( body.operation === 'crowdstrike_get_sensor_aggregates' ? await executeSensorOperation(body, baseUrl, accessToken, signal) : await executeCrowdStrikeOperation(body, baseUrl, accessToken, signal) - if (inline && !result.ok && result.status === 401) invalidateAccessToken(inline) + if (inline && !result.ok && (result.status === 401 || result.status === 403)) { + invalidateAccessToken(inline) + } return result } diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts index d6e1688bc77..89c1583821b 100644 --- a/apps/sim/tools/crowdstrike/types.ts +++ b/apps/sim/tools/crowdstrike/types.ts @@ -13,12 +13,11 @@ export interface CrowdStrikeInlineCredentials { } /** - * Inline API client credentials, or the token and cloud origin the executor + * Inline API client credentials, or the token and Falcon cloud the executor * resolves from a saved CrowdStrike credential in their place. */ export interface CrowdStrikeBaseParams extends Partial { accessToken?: string - instanceUrl?: string } export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { From 9f8cdf4882e3c2eb8f3e0d79a21eaa9ad770f580 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:20:59 -0800 Subject: [PATCH 51/83] fix(credentials): expire shared client-credentials tokens the provider rejects A token served from the shared client_credential_token row was never expired early, so a Shopify or Atlassian token revoked before its stated expiry kept being served, and reconnecting with the same secret got the same dead token. - invalidateCoordinatedClientToken expires exactly the rejected token - saving or reconnecting re-mints once when the provider rejects the served token - a 401 from a tool call on a service-account credential expires the token - expired failure memos are swept by the shared coordinator, which Vanta now reuses - drop the Atlassian product check that could never refuse anything --- .../atlassian-client-credentials.ts | 18 +- .../client-credential-accounts/vanta-token.ts | 10 +- .../coordinated-client-token.integration.ts | 226 ++++++++++++++++++ .../credentials/coordinated-client-token.ts | 48 +++- .../service-account-secret.test.ts | 56 ++++- .../lib/credentials/service-account-secret.ts | 74 +++--- apps/sim/tools/index.test.ts | 79 ++++++ apps/sim/tools/index.ts | 33 ++- 8 files changed, 485 insertions(+), 59 deletions(-) create mode 100644 apps/sim/lib/credentials/coordinated-client-token.integration.ts diff --git a/apps/sim/lib/credentials/atlassian-client-credentials.ts b/apps/sim/lib/credentials/atlassian-client-credentials.ts index 6ac99509f87..2ef55b645c5 100644 --- a/apps/sim/lib/credentials/atlassian-client-credentials.ts +++ b/apps/sim/lib/credentials/atlassian-client-credentials.ts @@ -1,7 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' -import type { AtlassianProduct } from '@/lib/credentials/service-account-fields' import { fetchProvider, providerFailureCode, @@ -14,16 +13,6 @@ const MINT_STEP = 'atlassian_token_mint' /** Atlassian documents a 60-minute access token for service-account OAuth credentials. */ const MAX_TOKEN_LIFETIME_SECONDS = 3600 -/** - * Products whose REST APIs accept a service account's OAuth 2.0 client-credentials token. - * Atlassian documents Jira Cloud and Confluence Cloud (and Jira Align, which Sim does not call); - * a product outside this list is refused instead of saving a credential its tools cannot use. - */ -export const ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS: ReadonlySet = new Set([ - 'jira', - 'confluence', -]) - async function exchangeAtlassianToken( clientId: string, clientSecret: string, @@ -95,9 +84,10 @@ async function exchangeAtlassianToken( /** * Mints a bearer token for an Atlassian service account's OAuth 2.0 credential through the * client-credentials grant. The token is used exactly like a scoped API token, through - * `api.atlassian.com/ex/{product}/{cloudId}`. Atlassian does not document whether a new token - * retires earlier ones, so every saved credential for one OAuth client shares a single - * coordinated token instead of minting per worker. + * `api.atlassian.com/ex/{product}/{cloudId}`. Atlassian documents these credentials for the Jira + * and Confluence REST APIs (not Jira Service Management, which shares Jira's credentials here), + * and does not document whether a new token retires earlier ones, so every saved credential for + * one OAuth client shares a single coordinated token instead of minting per worker. * * @see https://support.atlassian.com/user-management/docs/create-oauth-2-0-credential-for-service-accounts/ */ diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts index d3c5687e51b..86bd767c970 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts @@ -6,6 +6,7 @@ import { eq, inArray, sql } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors' +import { deleteExpiredClientTokenFailures } from '@/lib/credentials/coordinated-client-token' import { fetchProvider, isTransientProviderStatus, @@ -197,14 +198,7 @@ export async function resolveVantaClientToken( const secretFingerprint = digest([apiDomain, fields.clientId, clientSecret]) const failureKey = `${applicationKey}:${digest([secretFingerprint, scope])}` try { - // Release cleanup row locks before taking an application lock or calling the provider. - await db.execute(sql` - DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( - SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} - WHERE ${clientCredentialToken.accessTokenDigest} IS NULL - AND ${clientCredentialToken.expiresAt} < now() - ORDER BY ${clientCredentialToken.expiresAt} LIMIT 100 FOR UPDATE SKIP LOCKED - )`) + await deleteExpiredClientTokenFailures() const outcome = await db.transaction( async (tx) => { await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts new file mode 100644 index 00000000000..834507349fb --- /dev/null +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto' +import { db } from '@sim/db' +import { clientCredentialToken } from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { like, or } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { + type CoordinatedClientTokenInput, + invalidateCoordinatedClientToken, + resolveCoordinatedClientToken, +} from '@/lib/credentials/coordinated-client-token' +import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const NAMESPACE = `fixture${generateId().replaceAll('-', '')}` +const ownedKeyPrefixes = new Set([`${NAMESPACE}:`]) + +function applicationKey(namespace: string, application: readonly string[]): string { + return `${namespace}:${createHash('sha256').update(JSON.stringify(application)).digest('hex')}` +} + +/** A provider that issues numbered tokens per application and counts every exchange. */ +function provider(application: string) { + let issued = 0 + const exchange = vi.fn(async () => { + await sleep(10) + issued++ + return { + accessToken: `${application}:token-${issued}`, + expiresAt: new Date(Date.now() + 3_600_000), + } + }) + return { exchange } +} + +function input( + application: string, + exchange: CoordinatedClientTokenInput['exchange'], + clientSecret = 'secret-1' +): CoordinatedClientTokenInput { + return { namespace: NAMESPACE, application: [application], clientSecret, step: 'mint', exchange } +} + +function rejecting() { + return vi.fn(async () => { + throw new TokenServiceAccountValidationError('invalid_credentials', 401, { step: 'mint' }) + }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +afterAll(async () => { + await db + .delete(clientCredentialToken) + .where( + or(...[...ownedKeyPrefixes].map((prefix) => like(clientCredentialToken.id, `${prefix}%`))) + ) +}) + +describe('coordinated client-credentials token against shared storage', () => { + it('exchanges once for simultaneous resolutions of one application', async () => { + const { exchange } = provider('concurrent') + + const results = await Promise.all( + Array.from({ length: 10 }, () => resolveCoordinatedClientToken(input('concurrent', exchange))) + ) + + expect(exchange).toHaveBeenCalledOnce() + expect(new Set(results.map((result) => result.accessToken))).toEqual( + new Set(['concurrent:token-1']) + ) + }) + + it('mints again for a rotated secret instead of serving the old secret its token', async () => { + const { exchange } = provider('rotated') + await resolveCoordinatedClientToken(input('rotated', exchange)) + + const rotated = await resolveCoordinatedClientToken(input('rotated', exchange, 'secret-2')) + + expect(rotated.accessToken).toBe('rotated:token-2') + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('keeps serving the good token after a wrong secret is rejected', async () => { + const { exchange } = provider('wrong-secret') + const good = await resolveCoordinatedClientToken(input('wrong-secret', exchange)) + + await expect( + resolveCoordinatedClientToken(input('wrong-secret', rejecting(), 'wrong')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + + await expect(resolveCoordinatedClientToken(input('wrong-secret', exchange))).resolves.toEqual( + expect.objectContaining({ accessToken: good.accessToken }) + ) + expect(exchange).toHaveBeenCalledOnce() + }) + + it('replays a rejected exchange until the failure memo expires', async () => { + const exchange = rejecting() + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(exchange).toHaveBeenCalledOnce() + + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, ['memo'])}:%`)) + + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('reports an exchange that fails outside validation as provider_unavailable and stores nothing', async () => { + const exchange = vi.fn(async () => { + throw new Error('socket hang up') + }) + + await expect(resolveCoordinatedClientToken(input('network', exchange))).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + + const rows = await db + .select({ id: clientCredentialToken.id }) + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, ['network'])}%`)) + expect(rows).toEqual([]) + }) + + it('mints a replacement once a provider-rejected token is invalidated', async () => { + const { exchange } = provider('revoked') + const first = await resolveCoordinatedClientToken(input('revoked', exchange)) + + await Promise.all( + Array.from({ length: 4 }, () => invalidateCoordinatedClientToken(first.accessToken)) + ) + const renewed = await Promise.all( + Array.from({ length: 4 }, () => resolveCoordinatedClientToken(input('revoked', exchange))) + ) + + expect(new Set(renewed.map((result) => result.accessToken))).toEqual( + new Set(['revoked:token-2']) + ) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('leaves a replacement in service when a stale token is invalidated late', async () => { + const { exchange } = provider('late-invalidation') + const first = await resolveCoordinatedClientToken(input('late-invalidation', exchange)) + await invalidateCoordinatedClientToken(first.accessToken) + const replacement = await resolveCoordinatedClientToken(input('late-invalidation', exchange)) + + await invalidateCoordinatedClientToken(first.accessToken) + + await expect( + resolveCoordinatedClientToken(input('late-invalidation', exchange)) + ).resolves.toMatchObject({ accessToken: replacement.accessToken }) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('sweeps expired failure memos', async () => { + await expect( + resolveCoordinatedClientToken(input('swept', rejecting(), 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + const failurePrefix = `${applicationKey(NAMESPACE, ['swept'])}:` + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(like(clientCredentialToken.id, `${failurePrefix}%`)) + + await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange)) + + const rows = await db + .select({ id: clientCredentialToken.id }) + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${failurePrefix}%`)) + expect(rows).toEqual([]) + }) +}) + +describe('saving a Shopify client-credentials credential', () => { + const shop = `fixture-${generateId().slice(0, 8)}.myshopify.com` + const clientId = `client-${generateId()}` + ownedKeyPrefixes.add(applicationKey('shopify', [shop, clientId])) + + it('recovers when the shared token was revoked before it expired', async () => { + const active = new Set() + let issued = 0 + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + const target = new URL(typeof url === 'string' || url instanceof URL ? url : url.url) + if (target.host !== shop) throw new Error('Unexpected provider target') + if (target.pathname === '/admin/oauth/access_token') { + issued++ + const token = `shpca_${issued}` + active.add(token) + return Response.json({ access_token: token, scope: 'read_products', expires_in: 86399 }) + } + const token = new Headers(init?.headers).get('x-shopify-access-token') ?? '' + if (!active.has(token)) { + return Response.json({ errors: '[API] Invalid API key or access token' }, { status: 401 }) + } + return Response.json({ data: { shop: { name: 'Fixture', myshopifyDomain: shop } } }) + }) + const fields = { + authMethod: 'client_credentials', + clientId, + clientSecret: 'shopify-secret', + domain: shop, + } + await verifyAndBuildServiceAccountSecret('shopify-service-account', fields) + active.clear() + + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', fields) + ).resolves.toMatchObject({ providerId: 'shopify-service-account' }) + expect(issued).toBe(2) + }) +}) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index a600fdb86da..8eaed8d5cfd 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -60,6 +60,38 @@ function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex') } +/** Identifies a stored token by its value alone, so a provider rejection can expire it. */ +function accessTokenDigestOf(accessToken: string): string { + return digest(['coordinated_client_token', accessToken]) +} + +/** + * Deletes a bounded batch of expired failure memos (rows without a token digest). Run it outside + * the transaction that takes an application lock, so its row locks are released before any + * provider call. + */ +export async function deleteExpiredClientTokenFailures(): Promise { + await db.execute(sql` + DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( + SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} + WHERE ${clientCredentialToken.accessTokenDigest} IS NULL + AND ${clientCredentialToken.expiresAt} < now() + ORDER BY ${clientCredentialToken.expiresAt} LIMIT 100 FOR UPDATE SKIP LOCKED + )`) +} + +/** + * Expires a stored token the provider rejected before its stated expiry (an app reinstall, a scope + * change, a revocation), so the next resolution mints a replacement. Only that exact token is + * expired: a replacement another worker already minted stays in service. + */ +export async function invalidateCoordinatedClientToken(accessToken: string): Promise { + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(0), updatedAt: new Date() }) + .where(eq(clientCredentialToken.accessTokenDigest, accessTokenDigestOf(accessToken))) +} + function unavailable(step: string, reason: string): TokenServiceAccountValidationError { return new TokenServiceAccountValidationError('provider_unavailable', 503, { step, reason }) } @@ -98,11 +130,14 @@ function isServable(expiresAt: Date): boolean { /** * Serves one client-credentials token per provider application across every saved credential, - * worker, and connect, for providers that do not document whether minting a new token leaves the - * previous one valid. Minting happens only under a transaction-scoped advisory lock keyed by the - * application, so two workers never race two tokens into existence; a token minted with a - * different secret is never served. A rejected exchange is remembered for 30 seconds so a hot - * workflow on a revoked secret does not hammer the token endpoint. Tokens are encrypted at rest. + * worker, and connect, until shortly before it expires. Used for providers that do not document + * whether minting a new token leaves the previous one valid, and for providers that ask clients + * to reuse one token rather than accumulate them. Minting happens only under a + * transaction-scoped advisory lock keyed by the application, so two workers never race two + * tokens into existence; a token minted with a different secret is never served, and a token the + * provider rejects is expired through {@link invalidateCoordinatedClientToken}. A rejected + * exchange is remembered for 30 seconds so a hot workflow on a revoked secret does not hammer the + * token endpoint. Tokens are encrypted at rest. */ export async function resolveCoordinatedClientToken( input: CoordinatedClientTokenInput @@ -129,6 +164,7 @@ export async function resolveCoordinatedClientToken( const fresh = await serveIfFresh(current) if (fresh) return fresh + await deleteExpiredClientTokenFailures() const outcome = await db.transaction( async (tx) => { await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) @@ -156,7 +192,7 @@ export async function resolveCoordinatedClientToken( ) const values = { encryptedValue: encrypted, - accessTokenDigest: digest([applicationKey, exchanged.accessToken]), + accessTokenDigest: accessTokenDigestOf(exchanged.accessToken), expiresAt: exchanged.expiresAt, } await tx diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index e78486fd173..e1fd04b83af 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -23,10 +23,14 @@ vi.mock('@/lib/credentials/coordinated-client-token', () => ({ const exchanged = await input.exchange() return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } }, + invalidateCoordinatedClientToken: async () => {}, })) const mockEncryptSecret = encryptionMockFns.mockEncryptSecret vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId })) -vi.mock('@/lib/credentials/atlassian-service-account', () => ({ +vi.mock('@/lib/credentials/atlassian-service-account', async (importOriginal) => ({ + AtlassianValidationError: ( + await importOriginal() + ).AtlassianValidationError, validateAtlassianServiceAccount: mockValidateAtlassian, normalizeAtlassianDomain: mockNormalizeDomain, })) @@ -65,6 +69,7 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ : undefined, })) +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, @@ -155,6 +160,55 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.encryptedServiceAccountKey).not.toContain('atl-minted') }) + it('replaces a shared Atlassian token the site rejects, once', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockImplementation(async () => + Response.json({ access_token: `atl-${fetchMock.mock.calls.length}`, expires_in: 3600 }) + ) + mockValidateAtlassian.mockImplementation(async (token: string) => { + if (token !== 'atl-2') throw new AtlassianValidationError('invalid_credentials', 401) + return { accountId: 'svc-1', displayName: 'Workflow bot', cloudId: 'cloud-1' } + }) + const fields = { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + } + + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, fields) + ).resolves.toMatchObject({ displayName: 'Workflow bot' }) + + mockValidateAtlassian.mockRejectedValue( + new AtlassianValidationError('invalid_credentials', 401) + ) + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, fields) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it('does not mint again when the Atlassian site is unavailable', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + mockValidateAtlassian.mockRejectedValue( + new AtlassianValidationError('atlassian_unavailable', 503) + ) + + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + }) + ).rejects.toMatchObject({ code: 'atlassian_unavailable' }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + it('requires the Atlassian client ID, secret, and site without minting', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 1b923b02f2e..ee2a7a7ce5f 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -2,11 +2,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { encryptSecret } from '@/lib/core/security/encryption' +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' import { - ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS, - mintAtlassianServiceAccountToken, -} from '@/lib/credentials/atlassian-client-credentials' -import { + AtlassianValidationError, normalizeAtlassianDomain, validateAtlassianServiceAccount, } from '@/lib/credentials/atlassian-service-account' @@ -27,6 +25,7 @@ import { type ClientCredentialAccountSecretBlob, getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' +import { invalidateCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' import { DatabricksCredentialError, verifyAndEncryptDatabricksCredential, @@ -52,7 +51,7 @@ import { SnowflakeCredentialError, verifyAndEncryptSnowflakeKeylessCredential, } from '@/lib/credentials/snowflake-service-account.server' -import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' +import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, @@ -61,6 +60,7 @@ import { TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, type TokenServiceAccountDescriptor, } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getTokenServiceAccountValidator, type TokenServiceAccountClientCredentialBlob, @@ -194,11 +194,35 @@ async function buildAtlassianServiceAccountSecret( } } +/** + * Proves a client-credentials token with the provider's own identity check. Minted tokens are + * shared across every credential for one application, so the token served can have been revoked + * before its stated expiry (an app reinstall, a scope change). When the provider rejects it, that + * token is expired and one freshly minted token is proven instead, so saving or reconnecting the + * credential always recovers from a revoked token. + */ +async function proveMintedToken( + mint: () => Promise<{ accessToken: string }>, + prove: (accessToken: string) => Promise +): Promise { + const { accessToken } = await mint() + try { + return await prove(accessToken) + } catch (error) { + const rejected = + (error instanceof TokenServiceAccountValidationError || + error instanceof AtlassianValidationError) && + error.code === 'invalid_credentials' + if (!rejected) throw error + await invalidateCoordinatedClientToken(accessToken) + return prove((await mint()).accessToken) + } +} + /** * Builds an Atlassian service-account secret for an OAuth 2.0 credential: mints a token with the * client ID and secret, proves it against the product's identity endpoint exactly as a scoped - * API token is proven, and stores the client credentials so execution re-mints. Only products - * Atlassian documents for these credentials are accepted. + * API token is proven, and stores the client credentials so execution re-mints. */ async function buildAtlassianClientCredentialSecret( fields: ServiceAccountSecretFields @@ -212,17 +236,10 @@ async function buildAtlassianClientCredentialSecret( ) } const product = fields.atlassianProduct ?? 'jira' - if (!ATLASSIAN_CLIENT_CREDENTIAL_PRODUCTS.has(product)) { - throw new ServiceAccountSecretError( - `Atlassian OAuth 2.0 credentials do not support ${product}; use a scoped API token` - ) - } const normalizedDomain = normalizeAtlassianDomain(domain) - const minted = await mintAtlassianServiceAccountToken(clientId, clientSecret) - const validation = await validateAtlassianServiceAccount( - minted.accessToken, - normalizedDomain, - product + const validation = await proveMintedToken( + () => mintAtlassianServiceAccountToken(clientId, clientSecret), + (accessToken) => validateAtlassianServiceAccount(accessToken, normalizedDomain, product) ) const principal: ServiceAccountPrincipal = { kind: 'user', @@ -593,8 +610,8 @@ async function buildTokenClientCredentialSecret( fields: ServiceAccountSecretFields ): Promise { const { providerId } = descriptor - const grant = getTokenClientCredentialGrant(providerId) - if (!grant) { + const mint = getTokenClientCredentialMinter(providerId) + if (!mint) { throw new ServiceAccountSecretError( `No client-credentials grant registered for service-account provider ${providerId}` ) @@ -608,16 +625,15 @@ async function buildTokenClientCredentialSecret( `clientId, clientSecret${requiresDomain ? ', and domain are' : ' are'} required for ${descriptor.serviceLabel} client credentials` ) } - const minted = await grant.mint({ - clientId, - clientSecret, - ...(requiresDomain ? { domain } : {}), - }) - const validation = await validator({ - apiToken: minted.accessToken, - ...(requiresDomain ? { domain } : {}), - authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, - }) + const validation = await proveMintedToken( + () => mint({ clientId, clientSecret, ...(requiresDomain ? { domain } : {}) }), + (accessToken) => + validator({ + apiToken: accessToken, + ...(requiresDomain ? { domain } : {}), + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + }) + ) const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountClientCredentialBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4534fed46d0..2e88fbbf678 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -220,6 +220,15 @@ vi.mock('@/executor/utils/credential-token', () => ({ mockResolveExecutorCredentialToken(...args), })) +const { mockInvalidateCoordinatedClientToken } = vi.hoisted(() => ({ + mockInvalidateCoordinatedClientToken: vi.fn(), +})) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + invalidateCoordinatedClientToken: (...args: unknown[]) => + mockInvalidateCoordinatedClientToken(...args), +})) + vi.mock('@/executor/handlers/workflow/tool-runner', () => ({ runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args), })) @@ -3670,6 +3679,76 @@ describe('Internal Route Trust', () => { } }) + describe('a service-account token the provider rejects', () => { + const toolId = 'test_minted_token_rejection' + beforeEach(() => { + mockInvalidateCoordinatedClientToken.mockReset() + mockInvalidateCoordinatedClientToken.mockResolvedValue(undefined) + ;(tools as Record)[toolId] = { + id: toolId, + name: 'Minted Token Rejection Test', + description: 'Calls a provider with a resolved token', + version: '1.0.0', + oauth: { required: true, provider: 'shopify' }, + params: { accessToken: { type: 'string', required: true, visibility: 'hidden' } }, + request: { + url: 'https://acme.myshopify.com/admin/api/graphql.json', + method: 'POST' as const, + headers: (params: Record) => ({ + 'X-Shopify-Access-Token': String(params.accessToken), + }), + }, + } + }) + afterEach(() => { + Reflect.deleteProperty(tools, toolId) + }) + + async function callWith(credentialType: string, status: number) { + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'shared-token', + credentialType, + }) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + toSecureFetchResponse( + new Response(JSON.stringify({ error: 'rejected' }), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) + ) + return executeTool(toolId, { credential: 'saved-credential' }) + } + + it('expires the shared token on a 401 so the next call mints a replacement', async () => { + const result = await callWith('service_account', 401) + + expect(result.success).toBe(false) + expect(mockInvalidateCoordinatedClientToken).toHaveBeenCalledWith('shared-token') + }) + + it('keeps the token for a refusal that does not reject it', async () => { + await callWith('service_account', 403) + + expect(mockInvalidateCoordinatedClientToken).not.toHaveBeenCalled() + }) + + it('never touches shared tokens for a signed-in account', async () => { + await callWith('oauth', 401) + + expect(mockInvalidateCoordinatedClientToken).not.toHaveBeenCalled() + }) + + it('still reports the provider failure when the token cannot be expired', async () => { + mockInvalidateCoordinatedClientToken.mockRejectedValue(new Error('database unavailable')) + + const result = await callWith('service_account', 401) + + expect(result.success).toBe(false) + expect(result.error).toBe('rejected') + }) + }) + describe('Dataverse environment authority', () => { beforeEach(() => { ;(tools as Record)[dataverseWhoAmITool.id] = dataverseWhoAmITool diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f51e68b15f6..ebcf756d7db 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -2398,7 +2398,12 @@ async function executeToolImplementation( operationContext, effectiveSignal, resolvedSecretTraceRegistry - ) + ).catch(async (error: unknown) => { + if (authorizedServiceAccountCredential) { + await expireRejectedServiceAccountToken(error, contextParams.accessToken, requestId) + } + throw error + }) let finalResult = result if (tool.postProcess && result.success && !skipPostProcess) { @@ -3000,6 +3005,32 @@ async function executeDeclaredInternalOperation({ } } +/** + * A 401 means the provider no longer accepts the token a saved service-account credential + * resolved to. A token minted from client credentials is shared by every worker until it expires, + * so the rejected one is expired at once and the next call mints a replacement rather than + * re-sending it. A pasted token matches no shared token and is left alone. + */ +async function expireRejectedServiceAccountToken( + error: unknown, + accessToken: unknown, + requestId: string +): Promise { + const status = error instanceof Error && 'status' in error ? error.status : undefined + if (status !== 401 || typeof accessToken !== 'string' || !accessToken) return + try { + const { invalidateCoordinatedClientToken } = await import( + '@/lib/credentials/coordinated-client-token' + ) + await invalidateCoordinatedClientToken(accessToken) + } catch (invalidationError) { + logger.warn('Could not expire a service-account token the provider rejected', { + requestId, + error: toError(invalidationError).message, + }) + } +} + /** A tool or proxy URL the author configured that Sim refuses to call; the author's to fix. */ function invalidToolTarget(message: string): Error { return markFailureKind(new Error(message), 'user') From e4b976a5a19e78151886bd0b37c255f5a5771529 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:21:03 -0800 Subject: [PATCH 52/83] fix(credentials): report a mistyped SailPoint tenant as not found and share site lists - A SailPoint tenant host that does not resolve (ENOTFOUND) is site_not_found, not a provider outage; a transient resolver failure stays provider_unavailable. - The tenant normalizer refuses `api`, SailPoint's own API domain, in every input form instead of minting at api.api.identitynow.com. - Datadog site labels and the isDatadogSite guard live once in tools/datadog and serve the block, the connect modal, and the minter; CrowdStrike cloud labels derive from the cloud ids. --- apps/sim/blocks/blocks/datadog.ts | 13 +---- .../client-credential-accounts/descriptors.ts | 51 ++++++------------- .../minters/datadog.ts | 7 +-- .../minters/sailpoint.test.ts | 29 +++++++++++ .../minters/sailpoint.ts | 8 +++ apps/sim/tools/datadog/types.ts | 13 +++++ apps/sim/tools/datadog/utils.ts | 9 +++- 7 files changed, 76 insertions(+), 54 deletions(-) diff --git a/apps/sim/blocks/blocks/datadog.ts b/apps/sim/blocks/blocks/datadog.ts index 531a2bc065f..e9f6fae27c2 100644 --- a/apps/sim/blocks/blocks/datadog.ts +++ b/apps/sim/blocks/blocks/datadog.ts @@ -2,6 +2,7 @@ import { DatadogIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { withSavedCredential } from '@/blocks/utils' +import { DATADOG_SITE_LABELS, DATADOG_SITES } from '@/tools/datadog/types' /** * Normalizes a `switch` sub-block value. Switches serialize their state as the @@ -1909,17 +1910,7 @@ Return ONLY the search query string - no explanations.`, id: 'site', title: 'Datadog Site', type: 'dropdown', - options: [ - { label: 'US1 (datadoghq.com)', id: 'datadoghq.com' }, - { label: 'US3 (us3.datadoghq.com)', id: 'us3.datadoghq.com' }, - { label: 'US5 (us5.datadoghq.com)', id: 'us5.datadoghq.com' }, - { label: 'EU (datadoghq.eu)', id: 'datadoghq.eu' }, - { label: 'AP1 (ap1.datadoghq.com)', id: 'ap1.datadoghq.com' }, - { label: 'AP2 (ap2.datadoghq.com)', id: 'ap2.datadoghq.com' }, - { label: 'UK1 (uk1.datadoghq.com)', id: 'uk1.datadoghq.com' }, - { label: 'US1-FED (ddog-gov.com)', id: 'ddog-gov.com' }, - { label: 'US2-FED (us2.ddog-gov.com)', id: 'us2.ddog-gov.com' }, - ], + options: DATADOG_SITES.map((site) => ({ label: DATADOG_SITE_LABELS[site], id: site })), value: () => 'datadoghq.com', mode: 'advanced', }, diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 5c0447dae05..317c574be5d 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -10,8 +10,8 @@ * lives in `@/lib/credentials/client-credential-accounts/server`. */ -import { CROWDSTRIKE_CLOUDS, type CrowdStrikeCloud } from '@/tools/crowdstrike/types' -import { DATADOG_SITES, type DatadogSite } from '@/tools/datadog/types' +import { CROWDSTRIKE_CLOUDS } from '@/tools/crowdstrike/types' +import { DATADOG_SITE_LABELS, DATADOG_SITES } from '@/tools/datadog/types' /** Discriminator stored inside every encrypted client-credential secret blob. */ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const @@ -414,23 +414,29 @@ export const SAILPOINT_API_HOST_REGEX = const SAILPOINT_TENANT_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ const SAILPOINT_UI_HOST_REGEX = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.identitynow\.com$/ +/** `api.identitynow.com` is SailPoint's API domain itself, so `api` never names a tenant. */ +const SAILPOINT_API_DOMAIN_AS_TENANT = 'api.api.identitynow.com' /** * Normalizes a SailPoint tenant name, API host, UI host, or https URL of either * to the tenant's API host (`{tenant}.api.identitynow.com`), the domain * SailPoint documents for API calls and token requests. Returns `undefined` * for anything else (other domains, credentials, ports, more than one tenant - * label) rather than throwing, so the connect modal's format hint and the - * minter share one admission rule. + * label, or `api`, SailPoint's own API domain) rather than throwing, so the + * connect modal's format hint and the minter share one admission rule. * @see https://developer.sailpoint.com/docs/api/authentication */ export function normalizeSailPointTenantHost(rawTenant: string): string | undefined { - const trimmed = rawTenant.trim().toLowerCase() - if (!trimmed) return undefined - if (SAILPOINT_TENANT_LABEL_REGEX.test(trimmed)) return `${trimmed}.api.identitynow.com` + const host = toSailPointApiHost(rawTenant.trim().toLowerCase()) + return host === SAILPOINT_API_DOMAIN_AS_TENANT ? undefined : host +} + +function toSailPointApiHost(tenant: string): string | undefined { + if (!tenant) return undefined + if (SAILPOINT_TENANT_LABEL_REGEX.test(tenant)) return `${tenant}.api.identitynow.com` let parsed: URL try { - parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`) + parsed = new URL(tenant.includes('://') ? tenant : `https://${tenant}`) } catch { return undefined } @@ -499,29 +505,6 @@ function buildDocuSignConsentUrl( return `https://${DOCUSIGN_ENVIRONMENTS[resolved].authHost}/oauth/auth?${params.toString()}` } -/** Falcon cloud names as the Falcon console shows them, keyed by the block's cloud ids. */ -const CROWDSTRIKE_CLOUD_LABELS: Record = { - 'us-1': 'US-1', - 'us-2': 'US-2', - 'us-3': 'US-3', - 'eu-1': 'EU-1', - 'us-gov-1': 'US-GOV-1', - 'us-gov-2': 'US-GOV-2', -} - -/** Datadog sites as the legacy block's dropdown names them. */ -const DATADOG_SITE_LABELS: Record = { - 'datadoghq.com': 'US1 (datadoghq.com)', - 'us3.datadoghq.com': 'US3 (us3.datadoghq.com)', - 'us5.datadoghq.com': 'US5 (us5.datadoghq.com)', - 'datadoghq.eu': 'EU (datadoghq.eu)', - 'ap1.datadoghq.com': 'AP1 (ap1.datadoghq.com)', - 'ap2.datadoghq.com': 'AP2 (ap2.datadoghq.com)', - 'uk1.datadoghq.com': 'UK1 (uk1.datadoghq.com)', - 'ddog-gov.com': 'US1-FED (ddog-gov.com)', - 'us2.ddog-gov.com': 'US2-FED (us2.ddog-gov.com)', -} - export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor @@ -648,10 +631,8 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< label: 'Cloud', placeholder: 'Select a cloud', secret: false, - options: CROWDSTRIKE_CLOUDS.map((cloud) => ({ - value: cloud, - label: CROWDSTRIKE_CLOUD_LABELS[cloud], - })), + // The Falcon console names each cloud by its id in upper case (US-1, US-GOV-1). + options: CROWDSTRIKE_CLOUDS.map((cloud) => ({ value: cloud, label: cloud.toUpperCase() })), hint: 'The Falcon cloud your console signs in to.', }, { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts index a8f95a140db..fd9ba4a2531 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts @@ -11,8 +11,7 @@ import { TokenServiceAccountValidationError, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' -import { DATADOG_SITES, type DatadogSite } from '@/tools/datadog/types' -import { datadogApiUrl } from '@/tools/datadog/utils' +import { datadogApiUrl, isDatadogSite } from '@/tools/datadog/utils' const STEP = 'datadog_key_check' /** Datadog keys do not expire on their own; the TTL only bounds the local token cache. */ @@ -35,10 +34,6 @@ function assertKey(value: string, label: string): void { } } -function isDatadogSite(value: unknown): value is DatadogSite { - return typeof value === 'string' && (DATADOG_SITES as readonly string[]).includes(value) -} - /** * Resolves a saved Datadog key pair. Datadog authenticates every request with * two static headers, so nothing is minted: the API key travels as the diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts index 9bae7eb3828..adeee8d1d59 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts @@ -64,6 +64,10 @@ describe('SailPoint API client token exchange', () => { 'http://acme.api.identitynow.com', 'a.b.api.identitynow.com', 'acme.api.identitynowgov.com', + 'api', + 'api.identitynow.com', + 'https://api.identitynow.com/', + 'api.api.identitynow.com', ])('refuses the tenant %j before sending the secret anywhere', async (orgId) => { await expect(mintSailPointServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ code: 'invalid_credentials', @@ -93,6 +97,31 @@ describe('SailPoint API client token exchange', () => { }) }) + it('reports a tenant whose host does not resolve as a wrong tenant, not an outage', async () => { + mockFetch.mockRejectedValue( + new TypeError('fetch failed', { + cause: Object.assign(new Error('getaddrinfo'), { code: 'ENOTFOUND' }), + }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + }) + + it('keeps a transient DNS failure a provider failure', async () => { + mockFetch.mockRejectedValue( + new TypeError('fetch failed', { + cause: Object.assign(new Error('getaddrinfo'), { code: 'EAI_AGAIN' }), + }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + it('refuses a token response without an access token', async () => { mockFetch.mockResolvedValue(jsonResponse({ expires_in: 749 })) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts index 6ca648de741..6821786d3a0 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts @@ -5,6 +5,7 @@ import type { ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' import { + isHostNotFound, providerFailureCode, providerFailureReason, requireClientSecret, @@ -64,6 +65,13 @@ export async function mintSailPointServiceAccountToken( } ) } + if (isHostNotFound(error)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: STEP, + reason: 'tenant host does not resolve', + tenant: host, + }) + } throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: STEP, reason: providerFailureReason(error), diff --git a/apps/sim/tools/datadog/types.ts b/apps/sim/tools/datadog/types.ts index 587f0c8674b..736e523b035 100644 --- a/apps/sim/tools/datadog/types.ts +++ b/apps/sim/tools/datadog/types.ts @@ -22,6 +22,19 @@ export const DATADOG_SITES = [ export type DatadogSite = (typeof DATADOG_SITES)[number] +/** Each site's name in the site pickers: Datadog's region name and the site host. */ +export const DATADOG_SITE_LABELS: Record = { + 'datadoghq.com': 'US1 (datadoghq.com)', + 'us3.datadoghq.com': 'US3 (us3.datadoghq.com)', + 'us5.datadoghq.com': 'US5 (us5.datadoghq.com)', + 'datadoghq.eu': 'EU (datadoghq.eu)', + 'ap1.datadoghq.com': 'AP1 (ap1.datadoghq.com)', + 'ap2.datadoghq.com': 'AP2 (ap2.datadoghq.com)', + 'uk1.datadoghq.com': 'UK1 (uk1.datadoghq.com)', + 'ddog-gov.com': 'US1-FED (ddog-gov.com)', + 'us2.ddog-gov.com': 'US2-FED (us2.ddog-gov.com)', +} + /** Base parameters for write-only operations (only need API key) */ interface DatadogWriteOnlyParams { apiKey: string diff --git a/apps/sim/tools/datadog/utils.ts b/apps/sim/tools/datadog/utils.ts index b05578469bc..c3721ee99c5 100644 --- a/apps/sim/tools/datadog/utils.ts +++ b/apps/sim/tools/datadog/utils.ts @@ -37,12 +37,17 @@ export function datadogApiUrl(site: DatadogSite | undefined, path: string): stri */ export function resolveDatadogSite(site: unknown): DatadogSite { if (site === undefined || site === null || site === '') return 'datadoghq.com' - if (typeof site !== 'string' || !(DATADOG_SITES as readonly string[]).includes(site)) { + if (!isDatadogSite(site)) { throw new Error( `Datadog "site" must be one of ${DATADOG_SITES.join(', ')}, but was ${String(site)}` ) } - return site as DatadogSite + return site +} + +/** Narrows an untrusted value to one of Datadog's published sites in {@link DATADOG_SITES}. */ +export function isDatadogSite(value: unknown): value is DatadogSite { + return typeof value === 'string' && (DATADOG_SITES as readonly string[]).includes(value) } /** From cc5a868b900d1ff255edec049c52938cdf397f8d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:21:04 -0800 Subject: [PATCH 53/83] fix(credentials): drop the unverified ZoomInfo token-credits claim and cap saved tokens ZoomInfo's client-credentials docs do not say whether a token request uses credits, so the docs and comments describe only what Sim does: connecting requests a token and calls no data endpoint. A saved-credential accessToken is capped at 16 KiB like SailPoint's. --- apps/docs/content/docs/integrations/zoominfo.mdx | 2 +- .../client-credential-accounts/minters/zoominfo.ts | 4 ++-- apps/sim/lib/internal/zoominfo/client.ts | 2 +- apps/sim/lib/internal/zoominfo/schema.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/content/docs/integrations/zoominfo.mdx b/apps/docs/content/docs/integrations/zoominfo.mdx index 80b7f237b9f..0813a8f74e1 100644 --- a/apps/docs/content/docs/integrations/zoominfo.mdx +++ b/apps/docs/content/docs/integrations/zoominfo.mdx @@ -16,7 +16,7 @@ Use [ZoomInfo](https://www.zoominfo.com/) to search company and contact data, en **Search Intent** returns companies researching selected topics, signal scores, and recommended contacts. **Search News** filters by category, source URL, or publication date. -Connect ZoomInfo once as a ZoomInfo credential: create a client credentials app in the ZoomInfo Developer Portal, choose the integration user it acts as, and add its client ID and client secret. Sim checks the credential by requesting a token, which uses no credits, and requests a new token before each one expires. Every call acts as the app's integration user with the scopes configured on the app, and data calls consume credits as usual. Workflows built with the earlier ZoomInfo block keep the client ID and secret they entered in the block. +Connect ZoomInfo once as a ZoomInfo credential: create a client credentials app in the ZoomInfo Developer Portal, choose the integration user it acts as, and add its client ID and client secret. Sim checks the credential by requesting a token, without calling a data endpoint, and requests a new token before each one expires. Every call acts as the app's integration user with the scopes configured on the app, and data calls consume credits as usual. Workflows built with the earlier ZoomInfo block keep the client ID and secret they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts index 51fb8021c52..052d9c9fbde 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts @@ -36,8 +36,8 @@ function assertCredentialText(value: string, label: string): void { * acts as the app's integration user with the app's configured scopes; its * lifetime comes from `expires_in`. * - * The connect-time check is the mint alone: the token endpoint consumes no - * credits, and every data endpoint does, so none is called to verify. + * The connect-time check is the mint alone, so connecting calls no data + * endpoint that could consume credits. * * @see https://docs.zoominfo.com/docs/client-credentials-flow */ diff --git a/apps/sim/lib/internal/zoominfo/client.ts b/apps/sim/lib/internal/zoominfo/client.ts index 05ecdd4046c..f9c75e17bb7 100644 --- a/apps/sim/lib/internal/zoominfo/client.ts +++ b/apps/sim/lib/internal/zoominfo/client.ts @@ -93,7 +93,7 @@ function rememberToken(key: string, token: CachedToken): void { * Exchanges client credentials for an access token with the client * credentials grant at ZoomInfo's token endpoint, credentials in HTTP Basic * auth as ZoomInfo recommends, redirects refused. A refusal throws - * {@link ZoomInfoTokenError}; the endpoint consumes no credits. + * {@link ZoomInfoTokenError}. * * @see https://docs.zoominfo.com/docs/client-credentials-flow */ diff --git a/apps/sim/lib/internal/zoominfo/schema.ts b/apps/sim/lib/internal/zoominfo/schema.ts index 212d1d06374..54b79bc412b 100644 --- a/apps/sim/lib/internal/zoominfo/schema.ts +++ b/apps/sim/lib/internal/zoominfo/schema.ts @@ -11,7 +11,7 @@ export const ZOOMINFO_TOKEN_URL = `${ZOOMINFO_API_BASE}/oauth/v1/token` export const zoomInfoAuthSchema = z.object({ clientId: z.string().min(1, 'clientId is required').optional(), clientSecret: z.string().min(1, 'clientSecret is required').optional(), - accessToken: z.string().min(1).optional(), + accessToken: z.string().min(1).max(16_384).optional(), }) export const zoomInfoProviderRequestSchema = zoomInfoAuthSchema.extend({ From 5deb4309f5ed3952dd2fee5ae463fca52e735451 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:21:13 -0800 Subject: [PATCH 54/83] fix(credentials): share one Linear client-credentials token per app Linear asks clients to reuse a token until shortly before it expires and to request a new one after a 401, and caps an app at 1,000 tokens per workspace through cleanup. Each worker minted a new 30-day token every hour instead. Linear now goes through the coordinated token for its full lifetime, and the 401 path replaces it early; every token grant now coordinates, so the grant registry maps providers straight to minters. --- .../client-credentials.ts | 36 +++------ .../grants/linear.test.ts | 33 +++++++- .../token-service-accounts/grants/linear.ts | 76 ++++++++++++------- apps/sim/lib/oauth/credential-service.test.ts | 11 +-- apps/sim/lib/oauth/credential-service.ts | 13 ++-- 5 files changed, 96 insertions(+), 73 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts index f07729cee6f..b4ed87ae696 100644 --- a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts +++ b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts @@ -20,43 +20,29 @@ export interface TokenClientCredentialMint { expiresInSeconds: number } -export type TokenClientCredentialMinter = ( +type TokenClientCredentialMinter = ( fields: TokenClientCredentialFields, options?: { signal?: AbortSignal } ) => Promise -export interface TokenClientCredentialGrant { - mint: TokenClientCredentialMinter - /** - * The minter keeps one token per provider application across every worker itself, so the - * resolver must not cache its tokens per instance. Set for providers that do not document - * whether a new token leaves earlier ones valid. - */ - sharedToken: boolean -} - /** * Client-credentials grants offered beside the pasted token, for token providers whose * descriptor declares `clientCredentials`. The pasted-token mode is unchanged; a credential uses - * the grant only when it was saved with `authMethod: 'client_credentials'`. + * the grant only when it was saved with `authMethod: 'client_credentials'`. Every minter keeps + * one token per provider application across all workers itself (see + * `resolveCoordinatedClientToken`), so its tokens are never cached per instance. */ -const TOKEN_CLIENT_CREDENTIAL_GRANTS: Partial< - Record +const TOKEN_CLIENT_CREDENTIAL_MINTERS: Partial< + Record > = { - [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: { - mint: mintShopifyClientCredentialsToken, - sharedToken: true, - }, - [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: { - mint: mintLinearClientCredentialsToken, - sharedToken: false, - }, + [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: mintShopifyClientCredentialsToken, + [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: mintLinearClientCredentialsToken, } -export function getTokenClientCredentialGrant( +export function getTokenClientCredentialMinter( providerId: string -): TokenClientCredentialGrant | undefined { +): TokenClientCredentialMinter | undefined { return isTokenServiceAccountProviderId(providerId) - ? TOKEN_CLIENT_CREDENTIAL_GRANTS[providerId] + ? TOKEN_CLIENT_CREDENTIAL_MINTERS[providerId] : undefined } diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts index 53cda3af96d..4c14c91c30d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts @@ -1,5 +1,12 @@ import { jsonResponse } from '@sim/testing/helpers/http' import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' const FIELDS = { clientId: 'linear-client', clientSecret: 'linear-secret' } @@ -9,6 +16,16 @@ describe('Linear client credentials grant', () => { beforeEach(() => { mockFetch.mockReset() vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) }) it('requests an app token with the one fixed scope set every credential shares', async () => { @@ -37,14 +54,24 @@ describe('Linear client credentials grant', () => { expect(minted.accessToken).toBe('lin_oauth_1') }) - it('reuses a 30-day token for at most an hour so an early revocation heals', async () => { + it('shares one token per app across credentials for its 30-day lifetime', async () => { mockFetch.mockResolvedValue( jsonResponse({ access_token: 'lin_oauth_1', expires_in: 2_591_999 }) ) - const minted = await mintLinearClientCredentialsToken(FIELDS) + const minted = await mintLinearClientCredentialsToken({ + ...FIELDS, + clientId: ' linear-client ', + }) - expect(minted.expiresInSeconds).toBe(3600) + expect(minted.expiresInSeconds).toBeGreaterThan(2_591_000) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'linear', + application: ['linear-client'], + clientSecret: 'linear-secret', + }) + ) }) it.each([ diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts index 716c16f3c30..32dc87e76b3 100644 --- a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts @@ -1,5 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' import type { TokenClientCredentialFields, TokenClientCredentialMint, @@ -19,31 +20,15 @@ const STEP = 'linear_token_mint' * credentials for one app must never disagree. They match the scopes Linear's tools use. */ const LINEAR_CLIENT_CREDENTIAL_SCOPE = 'read,write' -/** - * Linear tokens last 30 days and cannot be refreshed, but a token revoked early (a secret - * rotation, or another scope request) answers 401 until it is replaced, so a minted token is - * reused for at most an hour before a fresh one is requested. - */ -const MAX_REUSE_SECONDS = 3600 +/** Linear documents a 30-day lifetime for client-credentials tokens, with no refresh token. */ +const MAX_TOKEN_LIFETIME_SECONDS = 30 * 24 * 60 * 60 -/** - * Mints an app-actor token for a Linear OAuth app with client credentials turned on. Tokens - * with the same scopes coexist, so each worker may hold its own. - * - * @see https://linear.app/developers/oauth-2-0-authentication - */ -export async function mintLinearClientCredentialsToken( - fields: TokenClientCredentialFields, - options?: { signal?: AbortSignal } -): Promise { - options?.signal?.throwIfAborted() - const clientId = fields.clientId.trim() - if (!clientId || !fields.clientSecret) { - throw new TokenServiceAccountValidationError('invalid_credentials', 400, { - step: STEP, - reason: 'client ID and client secret are required', - }) - } +async function exchangeLinearToken( + clientId: string, + clientSecret: string, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresAt: Date }> { + const startedAt = Date.now() const response = await fetchProvider( LINEAR_TOKEN_URL, { @@ -55,12 +40,12 @@ export async function mintLinearClientCredentialsToken( body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, - client_secret: fields.clientSecret, + client_secret: clientSecret, scope: LINEAR_CLIENT_CREDENTIAL_SCOPE, }).toString(), redirect: 'error', cache: 'no-store', - signal: options?.signal, + signal, }, STEP ) @@ -77,10 +62,10 @@ export async function mintLinearClientCredentialsToken( payload = await readResponseJsonWithLimit(response, { maxBytes: 64 * 1024, label: 'Linear token response', - signal: options?.signal, + signal, }) } catch { - options?.signal?.throwIfAborted() + signal?.throwIfAborted() payload = undefined } if ( @@ -98,6 +83,39 @@ export async function mintLinearClientCredentialsToken( } return { accessToken: payload.access_token, - expiresInSeconds: Math.min(payload.expires_in, MAX_REUSE_SECONDS), + expiresAt: new Date( + startedAt + Math.min(payload.expires_in, MAX_TOKEN_LIFETIME_SECONDS) * 1000 + ), + } +} + +/** + * Mints an app-actor token for a Linear OAuth app with client credentials turned on. Linear asks + * clients to reuse one token until shortly before it expires and to request a new one after a + * 401, and cleans up an app's surplus tokens past 1,000 per workspace, so every saved credential + * for one app shares a single coordinated token. A token Linear rejects early (a secret rotation, + * another scope request) is expired by the 401 path and replaced on the next call. + * + * @see https://linear.app/developers/oauth-2-0-authentication + */ +export async function mintLinearClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) } + return resolveCoordinatedClientToken({ + namespace: 'linear', + application: [clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => exchangeLinearToken(clientId, fields.clientSecret, signal), + signal: options?.signal, + }) } diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 6b3752035a5..ee12af33c9b 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -1413,7 +1413,7 @@ describe('token provider credentials saved in client-credentials mode', () => { ).resolves.toMatchObject({ accessToken: 'minted-2' }) }) - it('keeps a Linear app token on the instance and re-mints only when its secret changes', async () => { + it('leaves a Linear app token to the coordinator instead of caching it per instance', async () => { const linearBlob = { type: 'token_service_account', providerId: 'linear-service-account', @@ -1432,15 +1432,6 @@ describe('token provider credentials saved in client-credentials mode', () => { await expect( resolveServiceAccountToken('linear-cc', 'linear-service-account') ).resolves.toEqual({ accessToken: 'lin_oauth_1', domain: undefined }) - await expect( - resolveServiceAccountToken('linear-cc', 'linear-service-account') - ).resolves.toMatchObject({ accessToken: 'lin_oauth_1' }) - expect(fetchMock).toHaveBeenCalledOnce() - - resetDbChainMock() - for (let read = 0; read < 2; read++) { - queueTableRows(credential, [{ encryptedServiceAccountKey: 'enc-linear-cc-rotated' }]) - } await expect( resolveServiceAccountToken('linear-cc', 'linear-service-account') ).resolves.toMatchObject({ accessToken: 'lin_oauth_2' }) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 83c53139a53..069e65264dd 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -21,7 +21,7 @@ import { parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' -import { getTokenClientCredentialGrant } from '@/lib/credentials/token-service-accounts/client-credentials' +import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, @@ -851,15 +851,16 @@ function resolveClientCredentialAccountToken( * Resolves a token provider credential saved in client-credentials mode * (Shopify Dev Dashboard app, Linear OAuth app): mints with the stored client * ID and secret instead of returning a pasted token. The stored store domain - * travels with the token exactly as it does for a pasted one. + * travels with the token exactly as it does for a pasted one. Every grant + * shares one coordinated token per application, so none is cached per instance. */ function resolveTokenClientCredentialToken( credentialId: string, providerId: string, options?: CredentialTokenResolutionOptions ): Promise { - const grant = getTokenClientCredentialGrant(providerId) - if (!grant) { + const mint = getTokenClientCredentialMinter(providerId) + if (!mint) { return Promise.reject( new Error(`No client-credentials grant registered for service-account provider ${providerId}`) ) @@ -872,7 +873,7 @@ function resolveTokenClientCredentialToken( throw new Error('Stored token service-account secret changed mode') } // The mint is shared by every coalesced caller, so no one caller's signal may cancel it. - const minted = await grant.mint({ + const minted = await mint({ clientId: blob.clientId, clientSecret: blob.clientSecret, domain: blob.domain, @@ -883,7 +884,7 @@ function resolveTokenClientCredentialToken( } }, options, - grant.sharedToken + true ) } From 9134845ff44520d876042e03b7a70c4e62bb2dea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:21:14 -0800 Subject: [PATCH 55/83] fix(credentials): keep a credential's client-credentials mode on reconnect A reconnect that sent only a new client secret for a Shopify, Linear, or Atlassian client-credentials credential was routed to the pasted-token builder and failed. The stored mode now carries forward unless the reconnect sends a token. The connect modals no longer preselect a mode on reconnect, so the admin restates it instead of silently switching modes. --- .../connect-service-account-modal.tsx | 17 ++++-- .../token-service-account-modal.tsx | 21 +++++-- .../credentials/orchestration/index.test.ts | 60 +++++++++++++++++++ .../lib/credentials/orchestration/index.ts | 15 +++-- .../lib/credentials/service-account-fields.ts | 10 +++- 5 files changed, 108 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 4c91d88e704..586667ca573 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -149,7 +149,7 @@ function normalizeAtlassianDomain(raw: string): string { .replace(/\/+$/, '') } -function messageForAtlassianError(err: unknown, authMethod: AtlassianAuthMethod): string { +function messageForAtlassianError(err: unknown, authMethod: AtlassianAuthMethod | null): string { if (!isApiClientError(err) || !err.code) return FALLBACK_ERROR_MESSAGE const clientMessage = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD @@ -973,7 +973,13 @@ function AtlassianServiceAccountModalForm({ initialDescription, onCreated, }: AtlassianServiceAccountModalProps) { - const [authMethod, setAuthMethod] = useState('api_token') + /** + * A reconnect cannot pre-select the sign-in: the stored one lives inside the encrypted secret + * and is never returned to the client, so the admin restates it while retyping the secret. + */ + const [authMethod, setAuthMethod] = useState( + credentialId ? null : 'api_token' + ) const [apiToken, setApiToken] = useState('') const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') @@ -993,6 +999,7 @@ function AtlassianServiceAccountModalForm({ const isPending = createCredential.isPending || updateCredential.isPending const isDisabled = + !authMethod || (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || !normalizedDomain || isPending @@ -1055,7 +1062,7 @@ function AtlassianServiceAccountModalForm({ { setAuthMethod(value as AtlassianAuthMethod) clearError() @@ -1114,7 +1121,7 @@ function AtlassianServiceAccountModalForm({ )} - ) : ( + ) : authMethod === 'api_token' ? ( )} - )} + ) : null} (null) - const authMethod: TokenAuthMethod = + /** + * A reconnect cannot pre-select the mode: the stored one lives inside the encrypted secret and + * is never returned to the client, so the admin restates it while retyping the secret. Until + * then neither mode's fields show, so a reconnect never switches modes by default. + */ + const mustRestateAuthMethod = Boolean(clientCredentials && credentialId && !selectedAuthMethod) + const authMethod: TokenAuthMethod | null = selectedAuthMethod ?? - (clientCredentials?.preferred && !credentialId ? CLIENT_CREDENTIALS_AUTH_METHOD : 'token') + (mustRestateAuthMethod + ? null + : clientCredentials?.preferred + ? CLIENT_CREDENTIALS_AUTH_METHOD + : 'token') const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD const [apiToken, setApiToken] = useState('') const [clientId, setClientId] = useState('') @@ -125,6 +135,7 @@ function TokenServiceAccountModalForm({ const isPending = createCredential.isPending || updateCredential.isPending || submitPending === true const isDisabled = + !authMethod || (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || (Boolean(domainField) && !normalizedDomain) || (Boolean(workspaceField) && !providerWorkspaceId.trim()) || @@ -188,7 +199,7 @@ function TokenServiceAccountModalForm({ descriptor, code, onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined, - authMethod + authMethod ?? undefined ) ) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) @@ -209,7 +220,7 @@ function TokenServiceAccountModalForm({ { setSelectedAuthMethod(value as TokenAuthMethod) if (error) setError(null) @@ -256,7 +267,7 @@ function TokenServiceAccountModalForm({ )} - {tokenField && !usesClientCredentials && ( + {tokenField && authMethod === 'token' && ( { ) }) + it.each([ + [ + 'shopify-service-account', + { + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + }, + { domain: 'acme.myshopify.com' }, + ], + [ + 'atlassian-service-account', + { type: 'atlassian_service_account', authMethod: 'client_credentials' }, + { domain: 'acme.atlassian.net' }, + ], + ])( + 'keeps a %s credential on client credentials when a reconnect sends only a new secret', + async (providerId, storedBlob, extraFields) => { + mockCredential({ providerId, displayName: 'Fixture' }) + mockStoredBlob(storedBlob) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + clientSecret: 'rotated-secret', + ...extraFields, + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + providerId, + expect.objectContaining({ + authMethod: 'client_credentials', + clientSecret: 'rotated-secret', + }) + ) + } + ) + + it('switches a client-credentials credential to a pasted token when the reconnect sends one', async () => { + mockCredential({ providerId: 'shopify-service-account', displayName: 'Fixture' }) + mockStoredBlob({ + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'shpat_new', + domain: 'acme.myshopify.com', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'shopify-service-account', + expect.objectContaining({ apiToken: 'shpat_new', authMethod: undefined }) + ) + }) + it('carries the stored dataCenter forward for a client-credential reconnect', async () => { mockCredential({ providerId: 'zoho-desk-service-account', displayName: 'Acme Desk' }) mockIsClientCredentialAccountProviderId.mockReturnValue(true) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 497b7165009..5674a09afd1 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -40,9 +40,10 @@ import { import { GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE } from '@/lib/credentials/google-workload-identity' import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import { keylessFailure } from '@/lib/credentials/orchestration/credential-create' -import type { - AtlassianProduct, - ServiceAccountFieldId, +import { + type AtlassianProduct, + offersClientCredentialsMode, + type ServiceAccountFieldId, } from '@/lib/credentials/service-account-fields' import { ServiceAccountSecretError, @@ -352,7 +353,13 @@ export async function updateCredentialRecord( const isMultiGrantProvider = Boolean( getClientCredentialAccountDescriptor(providerId)?.defaultAuthMethod ) - const needsStoredAuthMethod = params.authMethod === undefined && isMultiGrantProvider + // A token provider that also offers client credentials keeps the stored mode when the + // reconnect sends client credentials without restating it; sending a token selects the + // token mode, exactly as before the client-credentials mode existed. + const needsStoredAuthMethod = + params.authMethod === undefined && + (isMultiGrantProvider || + (offersClientCredentialsMode(providerId) && params.apiToken === undefined)) const needsStoredAtlassianProduct = providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && params.atlassianProduct === undefined diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 6413561d0da..60113ef6bb7 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -116,13 +116,21 @@ const CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS: Record Date: Wed, 11 Nov 2026 14:21:15 -0800 Subject: [PATCH 56/83] docs(credentials): align Zendesk, Linear, and Shopify copy with vendor docs - mark Zendesk API tokens as deprecated by Zendesk in the picker, hint, and docs - warn that a Linear client-credentials request with other scopes revokes tokens - say where Shopify's 24-hour token is kept instead of claiming it is never stored - reword comments that stated undocumented Linear and Zendesk behavior as fact --- .../content/docs/integrations/linear-service-account.mdx | 2 +- .../content/docs/integrations/shopify-service-account.mdx | 2 +- apps/docs/content/docs/integrations/zendesk.mdx | 2 +- .../credentials/client-credential-accounts/descriptors.ts | 3 ++- .../client-credential-accounts/minters/zendesk.ts | 6 +++--- .../lib/credentials/token-service-accounts/descriptors.ts | 2 +- .../credentials/token-service-accounts/validators/linear.ts | 5 +++-- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/docs/integrations/linear-service-account.mdx b/apps/docs/content/docs/integrations/linear-service-account.mdx index 9788ae0be19..64644777984 100644 --- a/apps/docs/content/docs/integrations/linear-service-account.mdx +++ b/apps/docs/content/docs/integrations/linear-service-account.mdx @@ -75,7 +75,7 @@ A Linear OAuth app with **client credentials tokens** turned on can connect with -Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim requests new ones itself. +Use a dedicated OAuth app for Sim. Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. A client credentials request for the app with **different scopes** replaces its existing tokens, so another service using the same app revokes Sim's tokens and Sim's requests (always `read,write`) revoke theirs. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim shares one token across all of its credentials for the app, and requests a new one before it expires or as soon as Linear rejects it. ## Use the credential in workflows [#using-the-credential-in-workflows] diff --git a/apps/docs/content/docs/integrations/shopify-service-account.mdx b/apps/docs/content/docs/integrations/shopify-service-account.mdx index 11f68eb1204..0dbacb38bbd 100644 --- a/apps/docs/content/docs/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/integrations/shopify-service-account.mdx @@ -34,7 +34,7 @@ Shopify's [client credentials grant](https://shopify.dev/docs/apps/build/authent In Sim, open **Integrations**, search for "Shopify", click **Add to Sim**, and choose **Add admin API token**. Leave **Authentication** on **Client credentials (Dev Dashboard app)**, then paste the client ID, the client secret, and the store domain - Save. Sim requests a token from Shopify and runs a scope-free `shop` query with it before saving. The client ID, client secret, and store domain are encrypted; the short-lived token is never stored with the credential + Save. Sim requests a token from Shopify and runs a scope-free `shop` query with it before saving. The client ID, client secret, and store domain are encrypted. Sim keeps one encrypted 24-hour token per app and store, separate from the credential, requests a new one before it expires, and replaces it early if Shopify rejects it diff --git a/apps/docs/content/docs/integrations/zendesk.mdx b/apps/docs/content/docs/integrations/zendesk.mdx index cf5fc981703..aacb7f4a99b 100644 --- a/apps/docs/content/docs/integrations/zendesk.mdx +++ b/apps/docs/content/docs/integrations/zendesk.mdx @@ -17,7 +17,7 @@ Use [Zendesk](https://www.zendesk.com/) in Sim to manage tickets, users, and org Add a Zendesk credential under **Integrations** and pick it in the block. A credential holds your subdomain (`your-company` from `your-company.zendesk.com`) and one of two sign-ins: - **OAuth client (client credentials)**: the unique identifier and secret of a **confidential** OAuth client from Admin Center (**Apps and integrations** > **APIs** > **OAuth Clients**), plus the permissions to request (read only, or read and write). Sim requests a one-hour token with [Zendesk's client credentials grant](https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/) when a workflow needs one. Every call runs as the user the OAuth client belongs to. -- **API token**: an agent or admin email and an [API token](https://developer.zendesk.com/api-reference/introduction/security-and-auth/). Every call runs as that agent. +- **API token**: an agent or admin email and an [API token](https://developer.zendesk.com/api-reference/introduction/security-and-auth/). Every call runs as that agent. Zendesk has deprecated API tokens and asks customers to move to OAuth, so prefer the OAuth client. When you save, Sim checks that the credential signs in as a real agent or admin of that subdomain. Workflows that still type an email and API token into a Zendesk block keep working unchanged. {/* MANUAL-CONTENT-END */} diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index ac4d61c6146..b6f05043ea2 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -602,7 +602,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< value: ZENDESK_AUTH_METHODS.clientCredentials, label: 'OAuth client (client credentials)', }, - { value: ZENDESK_AUTH_METHODS.apiToken, label: 'API token' }, + { value: ZENDESK_AUTH_METHODS.apiToken, label: 'API token (deprecated by Zendesk)' }, ], }, { @@ -657,6 +657,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< secret: true, optional: true, requiredForAuthMethods: [ZENDESK_AUTH_METHODS.apiToken], + hint: 'Zendesk has deprecated API tokens in favor of OAuth. Use an OAuth client unless you cannot create one.', }, ], defaultAuthMethod: ZENDESK_AUTH_METHODS.clientCredentials, diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts index 8489d3f0ae6..2ae8adbe30c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts @@ -119,9 +119,9 @@ async function exchangeClientCredentials( } /** - * Proves the credential signs in as a real staff member: Zendesk answers an unauthenticated - * request to `users/me` with an anonymous user whose id is null, and an end user cannot use the - * ticketing API the tools call. + * Proves the credential signs in as a real staff member. Zendesk allows anonymous callers on + * `users/me` but does not document that response's body, so a positive integer id is required + * rather than trusting any user object; an end user cannot use the ticketing API the tools call. */ async function verifyStaffUser( base: string, diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ab4a2f2d463..a813da50776 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -496,7 +496,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< label: 'OAuth app (client credentials)', tokenLabel: 'Personal API key (lin_api_)', helpText: - 'Turn on client credentials tokens for the OAuth app in Linear first. Requesting a token replaces an existing authorization of this app in the workspace, and rotating the client secret invalidates its tokens until you reconnect here.', + 'Turn on client credentials tokens for the OAuth app in Linear first, and use an OAuth app only Sim uses: requesting a token replaces an existing authorization of this app in the workspace, a client-credentials request for it with other scopes revokes Sim’s tokens, and rotating the client secret invalidates its tokens until you reconnect here.', invalidCredentialsHelp: 'Linear rejected these client credentials. Check the client ID and secret, and that client credentials tokens are turned on for the OAuth app.', }, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 2673939e474..8eb355dbdab 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -115,8 +115,9 @@ export async function validateLinearServiceAccount( const payload = await parseProviderJson(res, 'viewer') const organization = payload.data?.organization - // A client-credentials token acts as the app, not a person: Linear documents no viewer for - // that actor, so the workspace it was minted for is the identity when no viewer comes back. + // A client-credentials token acts as the app, not a person. Linear does not document whether an + // app-actor token has a viewer, so the workspace it was minted for is the identity when none + // comes back. const appActor = fields.authMethod === CLIENT_CREDENTIALS_AUTH_METHOD if (appActor && !payload.data?.viewer?.id && organization?.id) { return { From 5d754d853e179448ffbafdc0d07c7a93873e98e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:31:16 -0800 Subject: [PATCH 57/83] fix(credentials): drop exports that only their own module uses --- .../lib/credentials/client-credential-accounts/descriptors.ts | 3 +-- apps/sim/lib/internal/crowdstrike/client.ts | 2 +- apps/sim/lib/internal/sailpoint/client.ts | 2 +- apps/sim/tools/shared/saved-credentials.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 317c574be5d..bc30582effb 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -409,8 +409,7 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): { } /** A SailPoint Identity Security Cloud API host: one tenant label under `api.identitynow.com`. */ -export const SAILPOINT_API_HOST_REGEX = - /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow\.com$/ +const SAILPOINT_API_HOST_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow\.com$/ const SAILPOINT_TENANT_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ const SAILPOINT_UI_HOST_REGEX = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.identitynow\.com$/ diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index 60563a7e293..c3f338d7993 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -216,7 +216,7 @@ export function invalidateAccessToken(params: CrowdStrikeInlineCredentials): voi } /** API client credentials for one Falcon token request. */ -export interface CrowdStrikeTokenRequest extends CrowdStrikeInlineCredentials { +interface CrowdStrikeTokenRequest extends CrowdStrikeInlineCredentials { /** The child CID an MSSP parent's token is locked to. */ memberCid?: string } diff --git a/apps/sim/lib/internal/sailpoint/client.ts b/apps/sim/lib/internal/sailpoint/client.ts index 65f7a008eb4..43d721cbfe5 100644 --- a/apps/sim/lib/internal/sailpoint/client.ts +++ b/apps/sim/lib/internal/sailpoint/client.ts @@ -18,7 +18,7 @@ export interface SailPointCredentials { } /** A token minted from a saved SailPoint credential, with the tenant host it was minted for. */ -export interface SailPointTokenCredentials { +interface SailPointTokenCredentials { accessToken: string tenant: string } diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index 9922f677893..d629d1f8c72 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -65,7 +65,7 @@ export function tokenCredentialOAuth( * plus every other mapped param (a cloud, site, or tenant the account is bound * to); the inline secrets in `tokenReplacesParams` are then never read. */ -export function clientCredentialOAuth( +function clientCredentialOAuth( provider: OAuthService, credentialParams: Readonly>, tokenReplacesParams: Replaced From 809df35c7c6e4154cabca2883d6d5cdb093b50fa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:38:36 -0800 Subject: [PATCH 58/83] fix(credentials): keep shared tokens on scope 401s and rate-limit tool-call expiry A 401 whose body reports a scope mismatch (Atlassian's 'Unauthorized; scope does not match', RFC 6750 insufficient_scope) comes with a valid token, so it no longer expires the application's shared token. A tool-call rejection also leaves a token minted within the last minute in service, so a call that keeps failing re-mints at most once a minute. Connect-time identity proofs still expire a rejected token unconditionally. --- .../coordinated-client-token.integration.ts | 25 ++++++++++++- .../credentials/coordinated-client-token.ts | 31 ++++++++++++---- apps/sim/tools/index.test.ts | 23 ++++++++++-- apps/sim/tools/index.ts | 35 ++++++++++++++++--- 4 files changed, 99 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts index 834507349fb..845cb65a8c1 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.integration.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { clientCredentialToken } from '@sim/db/schema' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { like, or } from 'drizzle-orm' +import { eq, like, or } from 'drizzle-orm' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { type CoordinatedClientTokenInput, @@ -166,6 +166,29 @@ describe('coordinated client-credentials token against shared storage', () => { expect(exchange).toHaveBeenCalledTimes(2) }) + it('keeps a token minted within the minimum age when a tool call reports it rejected', async () => { + const { exchange } = provider('fresh-rejection') + const first = await resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + + for (let attempt = 0; attempt < 3; attempt++) { + await invalidateCoordinatedClientToken(first.accessToken, { minimumAgeMs: 60_000 }) + await expect( + resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + ).resolves.toMatchObject({ accessToken: first.accessToken }) + } + expect(exchange).toHaveBeenCalledOnce() + + await db + .update(clientCredentialToken) + .set({ updatedAt: new Date(Date.now() - 120_000) }) + .where(eq(clientCredentialToken.id, applicationKey(NAMESPACE, ['fresh-rejection']))) + await invalidateCoordinatedClientToken(first.accessToken, { minimumAgeMs: 60_000 }) + + await expect( + resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + ).resolves.toMatchObject({ accessToken: 'fresh-rejection:token-2' }) + }) + it('sweeps expired failure memos', async () => { await expect( resolveCoordinatedClientToken(input('swept', rejecting(), 'revoked')) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index 8eaed8d5cfd..b565969ac1c 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' import { clientCredentialToken } from '@sim/db/schema' import { isRecordLike } from '@sim/utils/object' -import { eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, lt, sql } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' @@ -80,16 +80,35 @@ export async function deleteExpiredClientTokenFailures(): Promise { )`) } +interface InvalidateCoordinatedClientTokenOptions { + /** + * Keeps the token when it was minted less than this long ago. For rejections that may come from + * the endpoint rather than the token, so a call that keeps failing cannot re-mint on every try. + */ + minimumAgeMs?: number +} + /** * Expires a stored token the provider rejected before its stated expiry (an app reinstall, a scope * change, a revocation), so the next resolution mints a replacement. Only that exact token is * expired: a replacement another worker already minted stays in service. */ -export async function invalidateCoordinatedClientToken(accessToken: string): Promise { +export async function invalidateCoordinatedClientToken( + accessToken: string, + options: InvalidateCoordinatedClientTokenOptions = {} +): Promise { + const matchesToken = eq(clientCredentialToken.accessTokenDigest, accessTokenDigestOf(accessToken)) await db .update(clientCredentialToken) .set({ expiresAt: new Date(0), updatedAt: new Date() }) - .where(eq(clientCredentialToken.accessTokenDigest, accessTokenDigestOf(accessToken))) + .where( + options.minimumAgeMs + ? and( + matchesToken, + lt(clientCredentialToken.updatedAt, new Date(Date.now() - options.minimumAgeMs)) + ) + : matchesToken + ) } function unavailable(step: string, reason: string): TokenServiceAccountValidationError { @@ -194,14 +213,12 @@ export async function resolveCoordinatedClientToken( encryptedValue: encrypted, accessTokenDigest: accessTokenDigestOf(exchanged.accessToken), expiresAt: exchanged.expiresAt, + updatedAt: new Date(), } await tx .insert(clientCredentialToken) .values({ id: applicationKey, ...values }) - .onConflictDoUpdate({ - target: clientCredentialToken.id, - set: { ...values, updatedAt: new Date() }, - }) + .onConflictDoUpdate({ target: clientCredentialToken.id, set: values }) await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) return { accessToken: exchanged.accessToken, diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 2e88fbbf678..5af3a15a762 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -3704,14 +3704,18 @@ describe('Internal Route Trust', () => { Reflect.deleteProperty(tools, toolId) }) - async function callWith(credentialType: string, status: number) { + async function callWith( + credentialType: string, + status: number, + body: unknown = { error: 'rejected' } + ) { mockResolveExecutorCredentialToken.mockResolvedValue({ accessToken: 'shared-token', credentialType, }) mockSecureFetchWithPinnedIP.mockResolvedValueOnce( toSecureFetchResponse( - new Response(JSON.stringify({ error: 'rejected' }), { + new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }) @@ -3724,7 +3728,9 @@ describe('Internal Route Trust', () => { const result = await callWith('service_account', 401) expect(result.success).toBe(false) - expect(mockInvalidateCoordinatedClientToken).toHaveBeenCalledWith('shared-token') + expect(mockInvalidateCoordinatedClientToken).toHaveBeenCalledWith('shared-token', { + minimumAgeMs: 60_000, + }) }) it('keeps the token for a refusal that does not reject it', async () => { @@ -3733,6 +3739,17 @@ describe('Internal Route Trust', () => { expect(mockInvalidateCoordinatedClientToken).not.toHaveBeenCalled() }) + it.each([ + ['an Atlassian scope mismatch', { code: 401, message: 'Unauthorized; scope does not match' }], + ['a scope mismatch in a text body', 'Unauthorized; scope does not match'], + ['an RFC 6750 insufficient_scope error', { error: 'insufficient_scope' }], + ])('keeps the token for a 401 that reports %s', async (_label, body) => { + const result = await callWith('service_account', 401, body) + + expect(result.success).toBe(false) + expect(mockInvalidateCoordinatedClientToken).not.toHaveBeenCalled() + }) + it('never touches shared tokens for a signed-in account', async () => { await callWith('oauth', 401) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index ebcf756d7db..e65c1b559f3 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -3005,24 +3005,51 @@ async function executeDeclaredInternalOperation({ } } +/** + * A 401 whose body blames the scope rather than the token: Atlassian's gateway answers + * `Unauthorized; scope does not match` for a valid token, and RFC 6750 names `insufficient_scope`. + */ +const SCOPE_REFUSAL_PATTERN = /scope does not match|insufficient_scope/i +/** + * A tool call's 401 can come from the endpoint rather than the token, so a token minted within the + * last minute is kept: a call that keeps failing re-mints its application's token at most once a + * minute, instead of once per call. + */ +const REJECTED_TOKEN_MINIMUM_AGE_MS = 60_000 + +function reportsScopeRefusal(error: Error): boolean { + const data = 'data' in error ? error.data : undefined + if (typeof data === 'string') return SCOPE_REFUSAL_PATTERN.test(data) + if (data === undefined || data === null) return false + try { + return SCOPE_REFUSAL_PATTERN.test(JSON.stringify(data)) + } catch { + return false + } +} + /** * A 401 means the provider no longer accepts the token a saved service-account credential * resolved to. A token minted from client credentials is shared by every worker until it expires, * so the rejected one is expired at once and the next call mints a replacement rather than - * re-sending it. A pasted token matches no shared token and is left alone. + * re-sending it. A 401 that only reports a missing scope leaves the token in service, since a + * replacement would carry the same scopes. A pasted token matches no shared token and is left + * alone. */ async function expireRejectedServiceAccountToken( error: unknown, accessToken: unknown, requestId: string ): Promise { - const status = error instanceof Error && 'status' in error ? error.status : undefined - if (status !== 401 || typeof accessToken !== 'string' || !accessToken) return + if (!(error instanceof Error) || !('status' in error) || error.status !== 401) return + if (typeof accessToken !== 'string' || !accessToken || reportsScopeRefusal(error)) return try { const { invalidateCoordinatedClientToken } = await import( '@/lib/credentials/coordinated-client-token' ) - await invalidateCoordinatedClientToken(accessToken) + await invalidateCoordinatedClientToken(accessToken, { + minimumAgeMs: REJECTED_TOKEN_MINIMUM_AGE_MS, + }) } catch (invalidationError) { logger.warn('Could not expire a service-account token the provider rejected', { requestId, From 7bc4e609a7c2d745efc7746fc0cd5d19540249d6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:39:00 -0800 Subject: [PATCH 59/83] fix(credentials): replace the shared Linear client-credentials token daily Linear tokens live 30 days, but a revocation that does not surface as a 401 would otherwise keep a dead token in service until then. Same-scope tokens coexist, so a daily replacement leaves in-flight calls valid. --- .../integrations/linear-service-account.mdx | 2 +- .../grants/linear.test.ts | 5 +++-- .../token-service-accounts/grants/linear.ts | 19 ++++++++++++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/docs/integrations/linear-service-account.mdx b/apps/docs/content/docs/integrations/linear-service-account.mdx index 64644777984..71a9ddea4a8 100644 --- a/apps/docs/content/docs/integrations/linear-service-account.mdx +++ b/apps/docs/content/docs/integrations/linear-service-account.mdx @@ -75,7 +75,7 @@ A Linear OAuth app with **client credentials tokens** turned on can connect with -Use a dedicated OAuth app for Sim. Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. A client credentials request for the app with **different scopes** replaces its existing tokens, so another service using the same app revokes Sim's tokens and Sim's requests (always `read,write`) revoke theirs. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim shares one token across all of its credentials for the app, and requests a new one before it expires or as soon as Linear rejects it. +Use a dedicated OAuth app for Sim. Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. A client credentials request for the app with **different scopes** replaces its existing tokens, so another service using the same app revokes Sim's tokens and Sim's requests (always `read,write`) revoke theirs. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim shares one token across all of its credentials for the app, replaces it daily, and requests a new one as soon as Linear rejects it. ## Use the credential in workflows [#using-the-credential-in-workflows] diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts index 4c14c91c30d..177e6c109bd 100644 --- a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts @@ -54,7 +54,7 @@ describe('Linear client credentials grant', () => { expect(minted.accessToken).toBe('lin_oauth_1') }) - it('shares one token per app across credentials for its 30-day lifetime', async () => { + it('shares one token per app across credentials and replaces it daily', async () => { mockFetch.mockResolvedValue( jsonResponse({ access_token: 'lin_oauth_1', expires_in: 2_591_999 }) ) @@ -64,7 +64,8 @@ describe('Linear client credentials grant', () => { clientId: ' linear-client ', }) - expect(minted.expiresInSeconds).toBeGreaterThan(2_591_000) + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) expect(coordinated).toHaveBeenCalledWith( expect.objectContaining({ namespace: 'linear', diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts index 32dc87e76b3..fc515770b5e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts @@ -20,8 +20,13 @@ const STEP = 'linear_token_mint' * credentials for one app must never disagree. They match the scopes Linear's tools use. */ const LINEAR_CLIENT_CREDENTIAL_SCOPE = 'read,write' -/** Linear documents a 30-day lifetime for client-credentials tokens, with no refresh token. */ -const MAX_TOKEN_LIFETIME_SECONDS = 30 * 24 * 60 * 60 +/** + * Linear documents a 30-day lifetime for client-credentials tokens, but a token can also be + * revoked early (cleanup, another scope request) without every API answer saying so with a 401, so + * the shared token is replaced daily. Same-scope tokens coexist, so the replacement leaves the + * previous token valid for calls already using it. + */ +const SERVED_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60 async function exchangeLinearToken( clientId: string, @@ -84,17 +89,17 @@ async function exchangeLinearToken( return { accessToken: payload.access_token, expiresAt: new Date( - startedAt + Math.min(payload.expires_in, MAX_TOKEN_LIFETIME_SECONDS) * 1000 + startedAt + Math.min(payload.expires_in, SERVED_TOKEN_LIFETIME_SECONDS) * 1000 ), } } /** * Mints an app-actor token for a Linear OAuth app with client credentials turned on. Linear asks - * clients to reuse one token until shortly before it expires and to request a new one after a - * 401, and cleans up an app's surplus tokens past 1,000 per workspace, so every saved credential - * for one app shares a single coordinated token. A token Linear rejects early (a secret rotation, - * another scope request) is expired by the 401 path and replaced on the next call. + * clients to reuse one token and to request a new one after a 401, and cleans up an app's surplus + * tokens past 1,000 per workspace, so every saved credential for one app shares a single + * coordinated token, replaced daily. A token Linear rejects early with a 401 is expired by the + * tool-call path and replaced on the next call; a rotated secret never matches the stored token. * * @see https://linear.app/developers/oauth-2-0-authentication */ From 2b92c49e3c6c92924c75bd6bf60676070cafc6c2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:43:54 -0800 Subject: [PATCH 60/83] chore(credentials): regenerate block successors, tool metadata, deployment config, integration docs, OpenAPI and CLI API for credential auth wave 2 --- apps/docs/components/ui/icon-mapping.ts | 8 ++ .../content/docs/integrations/crowdstrike.mdx | 71 +--------- .../content/docs/integrations/datadog.mdx | 122 +----------------- apps/docs/content/docs/integrations/gong.mdx | 60 +-------- .../content/docs/integrations/hubspot.mdx | 2 +- .../docs/content/docs/integrations/intune.mdx | 2 +- .../content/docs/integrations/planetscale.mdx | 32 +---- .../content/docs/integrations/sailpoint.mdx | 122 +----------------- .../content/docs/integrations/servicenow.mdx | 107 +-------------- .../content/docs/integrations/zendesk.mdx | 80 +----------- .../content/docs/integrations/zoominfo.mdx | 14 +- apps/docs/openapi-v2-workflows.json | 8 ++ .../sim/lib/block-metadata/names.generated.ts | 16 +-- apps/sim/lib/integrations/icon-mapping.ts | 9 +- .../block-successors.generated.ts | 10 +- apps/sim/tools/generated/tool-metadata.ts | 2 +- .../src/integration-metadata.ts | 2 +- .../deployment-config/src/integrations.json | 60 +++++---- .../service-account-providers.generated.ts | 9 ++ packages/sim-cli/src/generated/v2-api.ts | 16 +++ 20 files changed, 112 insertions(+), 640 deletions(-) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 39e82ed79c6..b4347637bd8 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -409,6 +409,7 @@ export const blockTypeToIconMap: Record = { credential: CredentialIcon, credential_group: GridOffset, crowdstrike: CrowdStrikeIcon, + crowdstrike_v2: CrowdStrikeIcon, crunchbase: CrunchbaseIcon, crunchbase_v2: CrunchbaseIcon, cursor: CursorIcon, @@ -418,6 +419,7 @@ export const blockTypeToIconMap: Record = { databricks: DatabricksIcon, databricks_v2: DatabricksIcon, datadog: DatadogIcon, + datadog_v2: DatadogIcon, datagma: DatagmaIcon, daytona: DaytonaIcon, daytona_v2: DaytonaIcon, @@ -487,6 +489,7 @@ export const blockTypeToIconMap: Record = { gmail: GmailIcon, gmail_v2: GmailIcon, gong: GongIcon, + gong_v2: GongIcon, google_ads: GoogleAdsIcon, google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, @@ -658,6 +661,7 @@ export const blockTypeToIconMap: Record = { plane: PlaneIcon, plane_v2: PlaneIcon, planetscale: PlanetScaleIcon, + planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, @@ -703,6 +707,7 @@ export const blockTypeToIconMap: Record = { s3: S3Icon, s3_v2: S3Icon, sailpoint: SailPointIcon, + sailpoint_v2: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, @@ -719,6 +724,7 @@ export const blockTypeToIconMap: Record = { serper: SerperIcon, servicenow: ServiceNowIcon, servicenow_v2: ServiceNowIcon, + servicenow_v3: ServiceNowIcon, ses: SESIcon, ses_v2: SESIcon, sftp: SftpIcon, @@ -823,12 +829,14 @@ export const blockTypeToIconMap: Record = { youtube: YouTubeIcon, youtube_v2: YouTubeIcon, zendesk: ZendeskIcon, + zendesk_v2: ZendeskIcon, zep: ZepIcon, zep_v2: ZepIcon, zerobounce: ZeroBounceIcon, zoho_desk: ZohoDeskIcon, zoom: ZoomIcon, zoominfo: ZoomInfoIcon, + zoominfo_v2: ZoomInfoIcon, } export const coreBlockTypes = new Set([ diff --git a/apps/docs/content/docs/integrations/crowdstrike.mdx b/apps/docs/content/docs/integrations/crowdstrike.mdx index 62ea3b537fc..a459ba8f206 100644 --- a/apps/docs/content/docs/integrations/crowdstrike.mdx +++ b/apps/docs/content/docs/integrations/crowdstrike.mdx @@ -2,7 +2,7 @@ title: CrowdStrike description: Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities integration: - type: "crowdstrike" + type: "crowdstrike_v2" color: "#E01F3D" --- @@ -46,9 +46,6 @@ Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect \(prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts\); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | | `comment` | string | No | Audit comment explaining why these indicators were created | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the new indicators | @@ -108,9 +105,6 @@ Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicatorIds` | json | No | JSON array of CrowdStrike IOC IDs to delete. Cannot be combined with a filter. | | `filter` | string | No | Falcon Query Language filter selecting indicators to delete in bulk. Cannot be combined with an ID list. | | `comment` | string | No | Audit comment explaining why these indicators were deleted | @@ -138,9 +132,6 @@ Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-r | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `sessionId` | string | Yes | RTR session ID to close | #### Output @@ -166,9 +157,6 @@ Run a read-only Real Time Response command in an open CrowdStrike Falcon session | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `sessionId` | string | Yes | RTR session ID returned by Init RTR Session | | `baseCommand` | string | Yes | Read-only RTR base command family, one of: cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users. Subcommands belong in commandString, not here — and only reg query is read-tier, since reg set and reg delete are Active Responder commands. | | `commandString` | string | Yes | Full command line to run, such as "ls C:\Windows" or "reg query HKLM\Software" | @@ -197,9 +185,6 @@ Get full CrowdStrike Falcon alert records for one or more composite alert IDs (P | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `compositeIds` | json | Yes | JSON array of CrowdStrike composite alert IDs | | `includeHidden` | boolean | No | Include previously hidden alerts \(CrowdStrike defaults this to true\) | @@ -279,9 +264,6 @@ Get CrowdStrike Falcon Case Management case records for one or more case IDs (PO | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `caseIds` | json | Yes | JSON array of CrowdStrike case IDs | #### Output @@ -339,9 +321,6 @@ Get CrowdStrike Falcon host group records for one or more group IDs (GET /device | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `hostGroupIds` | json | Yes | JSON array of CrowdStrike host group IDs | #### Output @@ -376,9 +355,6 @@ Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or m | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicatorIds` | json | Yes | JSON array of CrowdStrike IOC IDs | #### Output @@ -435,9 +411,6 @@ Get the status and output of a Real Time Response command by cloud request ID (G | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `cloudRequestId` | string | Yes | Cloud request ID returned by Execute RTR Command | | `sequenceId` | number | No | Output chunk to retrieve, starting at 0 | @@ -469,9 +442,6 @@ Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query bo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `aggregateQuery` | json | Yes | JSON aggregate query body documented by CrowdStrike for sensor aggregates | #### Output @@ -511,9 +481,6 @@ Get CrowdStrike Identity Protection sensor details for one or more device IDs (P | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `ids` | json | Yes | JSON array of CrowdStrike sensor device IDs | #### Output @@ -562,9 +529,6 @@ Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerabi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `vulnerabilityIds` | json | Yes | JSON array of Spotlight vulnerability IDs \(maximum 400 per request\) | #### Output @@ -641,9 +605,6 @@ Open a CrowdStrike Falcon Real Time Response session against a host so read-only | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `deviceId` | string | Yes | CrowdStrike host agent ID \(AID\) to open the session against | | `queueOffline` | boolean | No | Queue the session so it runs when an offline host comes back online | | `origin` | string | No | Optional session origin string recorded by CrowdStrike | @@ -676,9 +637,6 @@ Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Act | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `actionName` | string | Yes | Action to take: contain, lift_containment, hide_host, unhide_host, detection_suppress, or detection_unsuppress. "contain" network-isolates the host; "hide_host" removes it from the Falcon console. | | `deviceIds` | json | Yes | JSON array of up to 100 CrowdStrike host agent IDs \(AIDs\) to act on | @@ -707,9 +665,6 @@ Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST / | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `actionName` | string | Yes | Action to take: add-hosts or remove-hosts | | `hostGroupId` | string | Yes | CrowdStrike host group ID to modify \(static groups only\) | | `deviceIds` | json | Yes | JSON array of CrowdStrike host agent IDs \(AIDs\) to add to or remove from the group | @@ -746,9 +701,6 @@ Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over alert fields | | `q` | string | No | Free-text search across all alert metadata | | `limit` | number | No | Maximum number of alert IDs to return \(max 10000\) | @@ -779,9 +731,6 @@ Search CrowdStrike Falcon Case Management cases with a Falcon Query Language fil | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter. Exact-match fields include cid and id; wildcard fields include assigned_to_name and assigned_to_uuid; range fields include created_timestamp and updated_timestamp. | | `q` | string | No | Free-text search across all case metadata | | `limit` | number | No | Maximum number of case IDs to return \(max 10000, default 100\) | @@ -811,9 +760,6 @@ Search CrowdStrike Falcon host groups with a Falcon Query Language filter and re | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over host group fields | | `limit` | number | No | Maximum number of host group IDs to return \(1-5000\) | | `offset` | number | No | Pagination offset for the host group query | @@ -842,9 +788,6 @@ Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Q | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over IOC fields | | `limit` | number | No | Maximum number of IOC IDs to return \(default 100\). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded | | `offset` | number | No | Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs. | @@ -875,9 +818,6 @@ Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter for identity sensor search | | `limit` | number | No | Maximum number of sensor records to return | | `offset` | number | No | Pagination offset for the identity sensor query | @@ -929,9 +869,6 @@ Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | Yes | Falcon Query Language filter \(required by Spotlight\). Filterable fields include status, aid, cid, last_seen_within, cve.id, cve.severity, cve.exprt_rating, cve.is_cisa_kev, cve.base_score, host_info.platform_name, host_info.groups, host_info.tags, host_info.internet_exposure, and suppression_info.is_suppressed. | | `limit` | number | No | Maximum number of vulnerability IDs to return \(1-400, default 100\) | | `after` | string | No | Pagination cursor from a previous response. Spotlight does not support offset. | @@ -960,9 +897,6 @@ Update CrowdStrike Falcon alerts by composite ID: change status, assign or unass | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `compositeIds` | json | Yes | JSON array of CrowdStrike composite alert IDs to update | | `updateStatus` | string | No | New alert status: new, in_progress, reopened, or closed | | `assignToUuid` | string | No | Assign the alert to this Falcon user UUID | @@ -1000,9 +934,6 @@ Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/ent | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicators` | json | Yes | JSON array of indicators to update. Each entry requires id, and should also repeat every field it wants to keep: an updatable field the entry omits may be cleared. Updatable fields: action, severity, description, source, tags \(array\), platforms \(array\), applied_globally \(boolean\), host_groups \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). type and value cannot be changed. | | `comment` | string | No | Audit comment explaining why these indicators were updated | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the updated indicators | diff --git a/apps/docs/content/docs/integrations/datadog.mdx b/apps/docs/content/docs/integrations/datadog.mdx index 1ae212e00d1..b143fadca99 100644 --- a/apps/docs/content/docs/integrations/datadog.mdx +++ b/apps/docs/content/docs/integrations/datadog.mdx @@ -2,7 +2,7 @@ title: Datadog description: Monitor infrastructure, applications, and logs with Datadog integration: - type: "datadog" + type: "datadog_v2" color: "#632CA6" --- @@ -32,8 +32,6 @@ Submit custom metrics to Datadog. Use for tracking application performance, busi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `series` | string | Yes | JSON array of metric series to submit. Each entry needs "metric" and "points" \(objects with "timestamp" in POSIX seconds and a numeric "value"\); timestamps cannot be more than 10 minutes in the future or 1 hour in the past. Optional per entry: "type" \("count", "rate", or "gauge"; omit to let Datadog infer\), "interval" in seconds \(required by Datadog for count and rate\), "tags", "unit", "sourceTypeName", and "resources". | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -57,9 +55,6 @@ Query metric timeseries data from Datadog. Use for analyzing trends, creating re | `query` | string | Yes | Datadog metrics query \(e.g., "avg:system.cpu.user\{*\}", "sum:nginx.requests\{env:prod\}.as_count\(\)"\) | | `from` | number | Yes | Start time as Unix timestamp in seconds \(e.g., 1705320000\) | | `to` | number | Yes | End time as Unix timestamp in seconds \(e.g., 1705323600\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -94,8 +89,6 @@ Post an event to the Datadog event stream. Use for deployment notifications, ale | `aggregationKey` | string | No | Key to aggregate events together | | `sourceTypeName` | string | No | Source type name for the event | | `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\). Datadog limits this to events no older than 18 hours. | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -131,9 +124,6 @@ Create a new monitor/alert in Datadog. Monitors can track metrics, service check | `tags` | string | No | Comma-separated list of tags | | `priority` | number | No | Monitor priority \(1-5, where 1 is highest\) | | `options` | string | No | JSON string of monitor options \(thresholds, notify_no_data, renotify_interval, etc.\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -168,9 +158,6 @@ Retrieve details of a specific monitor by ID. | `monitorId` | string | Yes | The ID of the monitor to retrieve \(e.g., "12345678"\) | | `groupStates` | string | No | Comma-separated group states to include. Valid values are "all", "alert", "warn", and "no data" \(e.g., "alert,warn"\). | | `withDowntimes` | boolean | No | Include downtime data with the monitor | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -209,9 +196,6 @@ List all monitors in Datadog with optional filtering by name, tags, or state. | `withDowntimes` | boolean | No | Include downtime data with monitors | | `page` | number | No | Page to start paginating from \(0-indexed, e.g., 0, 1, 2\). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0. | | `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100. | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -246,9 +230,6 @@ Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to | `monitorId` | string | Yes | The ID of the monitor to mute \(e.g., "12345678"\) | | `scope` | string | No | Scope to mute \(e.g., "host:myhost", "env:prod"\). If not specified, mutes all scopes. | | `end` | number | No | Unix timestamp in seconds when the mute should end \(e.g., 1705323600\). If not specified, the monitor stays muted until it is unmuted. | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -274,9 +255,6 @@ Unmute a monitor so it resumes sending notifications. Reverses Mute Monitor, eit | `monitorId` | string | Yes | The ID of the monitor to unmute \(e.g., "12345678"\) | | `scope` | string | No | Scope to unmute \(e.g., "host:myhost"\). Leave blank to unmute the monitor itself rather than a single scope. | | `allScopes` | boolean | No | Clear the mute settings for every scope on this monitor | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -306,9 +284,6 @@ Search and retrieve logs from Datadog. Use for troubleshooting, analysis, or mon | `cursor` | string | No | Pagination cursor from a previous call, taken from its nextLogId output. Omit for the first page. | | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `indexes` | string | No | Comma-separated list of log indexes to search | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -339,8 +314,6 @@ Send log entries to Datadog for centralized logging and analysis. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline. | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -368,9 +341,6 @@ Schedule a downtime to suppress monitor notifications during maintenance windows | `monitorId` | string | No | Specific monitor ID to mute \(e.g., "12345678"\) | | `monitorTags` | string | No | Comma-separated monitor tags to match \(e.g., "team:backend,priority:high"\) | | `muteFirstRecoveryNotification` | boolean | No | Mute the first recovery notification | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -402,9 +372,6 @@ List all scheduled downtimes in Datadog. | `currentOnly` | boolean | No | Only return currently active downtimes | | `limit` | number | No | Number of downtimes to return per page. Datadog defaults to 30 and declares no maximum; keep this at 100 or below to stay within the bound Sim recommends. | | `offset` | number | No | Index of the first downtime to return \(e.g., 0, 30, 60\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -435,9 +402,6 @@ Cancel a scheduled downtime. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `downtimeId` | string | Yes | The ID of the downtime to cancel \(e.g., "abc123def456"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -460,9 +424,6 @@ List incidents for the organization. Requires the Incident Management `incident_ | `include` | string | No | Comma-separated related resources to include: "users" and/or "attachments" | | `pageSize` | number | No | Number of incidents to return per page \(default: 10, max: 100\) | | `pageOffset` | number | No | Index of the first incident to return \(e.g., 0, 10, 20\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -496,9 +457,6 @@ Get the details of a single incident by ID. Requires the Incident Management `in | --------- | ---- | -------- | ----------- | | `incidentId` | string | Yes | The UUID of the incident \(e.g., "00000000-0000-0000-1234-000000000000"\) | | `include` | string | No | Comma-separated related resources to include \(e.g., "users", "attachments"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -539,9 +497,6 @@ Declare a new incident. Requires the Incident Management `incident_write` permis | `isTest` | boolean | No | Whether this is a test incident | | `fields` | string | No | JSON object of user-defined incident fields, e.g. \{"severity": \{"type": "dropdown", "value": "SEV-2"\}\} | | `notificationHandles` | string | No | Comma-separated handles to notify on creation \(e.g., "@slack-incidents,@user@example.com"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -579,9 +534,6 @@ Partially update an existing incident. Requires the Incident Management `inciden | `detected` | string | No | ISO-8601 timestamp when the incident was detected | | `fields` | string | No | JSON object of user-defined incident fields to update, e.g. \{"state": \{"type": "dropdown", "value": "resolved"\}\} | | `notificationHandles` | string | No | Comma-separated handles to notify about the update | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -613,9 +565,6 @@ Add a follow-up task (todo) to an incident. Requires the Incident Management `in | `content` | string | Yes | The follow-up task content \(e.g., "Restore lost data"\) | | `assignees` | string | Yes | Comma-separated assignee handles \(e.g., "@jane@example.com,@on-call"\). Datadog requires at least one assignee | | `dueDate` | string | No | ISO-8601 timestamp for when the todo should be completed | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -650,9 +599,6 @@ List service level objectives, optionally filtered by IDs, name, tags, or underl | `limit` | number | No | Number of SLOs to return \(default: 1000\) | | `offset` | number | No | Offset of the first SLO returned \(e.g., 0, 50\) | | `isDeleted` | boolean | No | Return only deleted SLOs | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -686,9 +632,6 @@ Get the configuration of a single service level objective by ID. | --------- | ---- | -------- | ----------- | | `sloId` | string | Yes | The ID of the service level objective | | `withConfiguredAlertIds` | boolean | No | Include the IDs of SLO monitors that reference this SLO | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -733,9 +676,6 @@ Create a service level objective from a metric query, monitors, or a time-slice | `targetThreshold` | number | No | Primary target threshold \(e.g., 99.9\) | | `warningThreshold` | number | No | Primary warning threshold, must be greater than the target \(e.g., 99.95\) | | `timeframe` | string | No | Primary timeframe: "7d", "30d", or "90d" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -775,9 +715,6 @@ Update a service level objective. Reads the current SLO first and applies only t | `targetThreshold` | number | No | Primary target threshold \(e.g., 99.9\) | | `warningThreshold` | number | No | Primary warning threshold, must be greater than the target \(e.g., 99.95\) | | `timeframe` | string | No | Primary timeframe: "7d", "30d", or "90d" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -806,9 +743,6 @@ Permanently delete a service level objective. Datadog returns a conflict when th | --------- | ---- | -------- | ----------- | | `sloId` | string | Yes | The ID of the service level objective to delete | | `force` | boolean | No | Delete even when the SLO is referenced by other resources | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -834,9 +768,6 @@ Get an SLO’s history over a time window, including the overall SLI value and r | `toTs` | number | Yes | End of the query window as a Unix timestamp in seconds | | `target` | number | No | SLO target between 0 and 100. When supplied, the response includes the remaining error budget for a custom timeframe | | `applyCorrection` | boolean | No | Whether to apply SLO corrections \(defaults to true\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -871,9 +802,6 @@ List custom created or cloned dashboards. Datadog preset dashboards are not retu | `filterDeleted` | boolean | No | Return only deleted dashboards. Incompatible with filterShared | | `count` | number | No | Maximum number of dashboards to return \(default: 100\) | | `start` | number | No | Offset of the first dashboard returned \(e.g., 0, 100\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -903,9 +831,6 @@ Get the full definition of a dashboard, including its widgets. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `dashboardId` | string | Yes | The ID of the dashboard \(e.g., "abc-def-ghi"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -946,9 +871,6 @@ Create a dashboard from a title, layout type, and widget definitions. | `templateVariables` | string | No | JSON array of template variable definitions, e.g. \[\{"name": "env", "prefix": "env", "available_values": \["prod"\]\}\] | | `tags` | string | No | Comma-separated dashboard tags in the form "team:<name>" \(max 5\) | | `reflowType` | string | No | Reflow type for ordered layouts: "auto" or "fixed" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -977,9 +899,6 @@ Delete a dashboard by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `dashboardId` | string | Yes | The ID of the dashboard to delete | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1002,9 +921,6 @@ List all Synthetic tests (API, browser, and mobile) with their current status. | --------- | ---- | -------- | ----------- | | `pageSize` | number | No | Number of tests returned per page \(default: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1034,9 +950,6 @@ Get the configuration of a Synthetic test by public ID. Browser test steps are n | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `publicId` | string | Yes | The public ID of the Synthetic test \(e.g., "abc-def-ghi"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1072,9 +985,6 @@ Get the latest result summaries (up to the last 150 runs) for a Synthetic API te | `fromTs` | number | No | Timestamp in milliseconds from which to start querying results | | `toTs` | number | No | Timestamp in milliseconds up to which to query results | | `probeDc` | string | No | Comma-separated locations to query results for \(e.g., "aws:eu-west-3"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1106,9 +1016,6 @@ Get the latest result summaries (up to the last 150 runs) for a Synthetic browse | `fromTs` | number | No | Timestamp in milliseconds from which to start querying results | | `toTs` | number | No | Timestamp in milliseconds up to which to query results | | `probeDc` | string | No | Comma-separated locations to query results for \(e.g., "aws:eu-west-3"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1140,9 +1047,6 @@ Trigger an immediate run of one or more Synthetic tests by public ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `publicIds` | string | Yes | Comma-separated public IDs of the Synthetic tests to trigger | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1173,9 +1077,6 @@ Pause or resume a Synthetic test by setting its status to "paused" or "live". | --------- | ---- | -------- | ----------- | | `publicId` | string | Yes | The public ID of the Synthetic test to update | | `newStatus` | string | Yes | New status: "live" to start the test or "paused" to pause it | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1202,9 +1103,6 @@ Search Cloud SIEM security signals by query and time range. Requires the `securi | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `cursor` | string | No | Pagination cursor returned as nextCursor by a previous call | | `limit` | number | No | Maximum number of signals to return \(default: 10, max: 1000\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1233,9 +1131,6 @@ Get the details of a single Cloud SIEM security signal. Requires the `security_m | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `signalId` | string | Yes | The ID of the security signal | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1266,9 +1161,6 @@ Change the triage state of a Cloud SIEM security signal to open, under_review, o | `state` | string | Yes | New triage state: "open", "under_review", or "archived" | | `archiveReason` | string | No | Reason when archiving: none, false_positive, testing_or_maintenance, remediated, investigated_case_opened, true_positive_benign, true_positive_malicious, or other | | `archiveComment` | string | No | Comment explaining why the signal was archived | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1298,9 +1190,6 @@ Assign a Cloud SIEM security signal to a Datadog user by UUID. Requires the `sec | --------- | ---- | -------- | ----------- | | `signalId` | string | Yes | The ID of the security signal | | `assigneeUuid` | string | Yes | UUID of the Datadog user to assign the signal to \(e.g., "773b045d-ccf8-4808-bd3b-955ef6a8c940"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1332,9 +1221,6 @@ List Cloud SIEM detection rules. Requires the `security_monitoring_rules_read` p | `sort` | string | No | Sort attribute, prefix with "-" for descending: name, creation_date, update_date, enabled, type, highest_severity, or source | | `pageSize` | number | No | Number of rules per page \(default: 10, max: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1369,9 +1255,6 @@ Search indexed APM spans using the span query syntax, with cursor pagination. | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `cursor` | string | No | Pagination cursor returned as nextCursor by a previous call | | `limit` | number | No | Maximum number of spans to return \(default: 10, max: 1000\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1411,9 +1294,6 @@ List service definitions from the Datadog Service Catalog. Requires the `apm_ser | `pageSize` | number | No | Number of service definitions per page \(default: 10, max: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | | `schemaVersion` | string | No | Schema version to return \(e.g., "v2", "v2.1", "v2.2"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output diff --git a/apps/docs/content/docs/integrations/gong.mdx b/apps/docs/content/docs/integrations/gong.mdx index 78c99697c27..9153c5ad977 100644 --- a/apps/docs/content/docs/integrations/gong.mdx +++ b/apps/docs/content/docs/integrations/gong.mdx @@ -2,7 +2,7 @@ title: Gong description: Read Gong data, upload calls, assign or unassign flow prospects, and request data erasure integration: - type: "gong" + type: "gong_v2" color: "#8039DF" --- @@ -33,8 +33,6 @@ Retrieve call data by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `fromDateTime` | string | Yes | Start date/time in ISO-8601 format \(e.g., 2024-01-01T00:00:00Z\) | | `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). Defaults to the current execution time when omitted. | | `cursor` | string | No | Pagination cursor from a previous response | @@ -83,8 +81,6 @@ Upload call metadata to Gong and let Gong pull the media from a URL. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `clientUniqueId` | string | Yes | Unique call ID from the source telephony or recording system | | `actualStart` | string | Yes | Actual call start time in ISO-8601 format | | `primaryUser` | string | Yes | Gong user ID for the call's host or owner | @@ -117,8 +113,6 @@ Retrieve detailed data for a specific call from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callId` | string | Yes | The Gong call ID to retrieve | #### Output @@ -159,8 +153,6 @@ Retrieve transcripts of calls from Gong by call IDs or date range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callIds` | string | No | Comma-separated list of call IDs to retrieve transcripts for | | `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | | `toDateTime` | string | No | End date/time filter in ISO-8601 format | @@ -195,8 +187,6 @@ Retrieve detailed call data including trackers, topics, highlights, and AI spotl | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callIds` | string | No | Comma-separated list of call IDs to retrieve detailed data for | | `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | | `toDateTime` | string | No | End date/time filter in ISO-8601 format | @@ -322,8 +312,6 @@ List all users in your Gong account. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `cursor` | string | No | Pagination cursor from a previous response | | `includeAvatars` | string | No | Whether to include avatar URLs \(true/false\) | @@ -375,8 +363,6 @@ Retrieve details for a specific user from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userId` | string | Yes | The Gong user ID to retrieve | #### Output @@ -422,8 +408,6 @@ Retrieve aggregated activity statistics for users by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -469,8 +453,6 @@ Retrieve detailed day-by-day activity (call IDs per activity type) for users by | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -516,8 +498,6 @@ Retrieve aggregated user activity grouped into time periods (day, week, month, q | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `aggregationPeriod` | string | Yes | Calendar period to group activity by: DAY, WEEK, MONTH, QUARTER, or YEAR \(week starts Monday\) | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | @@ -564,8 +544,6 @@ Retrieve interaction statistics for users by date range from Gong. Only includes | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -599,8 +577,6 @@ Retrieve answered scorecards for reviewed users or by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callFromDate` | string | No | Start date for calls in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest recorded call. | | `callToDate` | string | No | End date for calls in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest recorded call. | | `reviewFromDate` | string | No | Start date for reviews in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest reviewed call. | @@ -646,8 +622,6 @@ Retrieve library folders from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | No | Gong workspace ID to filter folders | #### Output @@ -674,8 +648,6 @@ Retrieve the list of calls in a specific library folder from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `folderId` | string | No | The library folder ID to retrieve content for \(up to 20 digits\) | #### Output @@ -710,8 +682,6 @@ Retrieve scorecard definitions from Gong settings. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | #### Output @@ -752,8 +722,6 @@ Retrieve smart tracker and keyword tracker definitions from Gong settings. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | No | The ID of the workspace the keyword trackers are in. When empty, all trackers in all workspaces are returned. | #### Output @@ -793,8 +761,6 @@ List all company workspaces in Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | #### Output @@ -818,8 +784,6 @@ List Gong Engage flows (sales engagement sequences). | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `flowOwnerEmail` | string | Yes | Email of a Gong user. The API will return 'PERSONAL' flows belonging to this user in addition to 'COMPANY' flows. | | `workspaceId` | string | No | Optional workspace ID to filter flows to a specific workspace | | `cursor` | string | No | Pagination cursor from a previous API call to retrieve the next page of records | @@ -854,8 +818,6 @@ Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `flowId` | string | Yes | The Gong Engage flow ID to assign the prospects to | | `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to assign | | `flowInstanceOwnerEmail` | string | Yes | Email of the Gong user who owns the flow instance and its to-dos | @@ -894,8 +856,6 @@ Remove a prospect from Gong Engage flows. Omit the flow ID to remove the prospec | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `crmProspectId` | string | Yes | The CRM ID of the prospect to unassign | | `flowId` | string | No | The ID of the flow to unassign the prospect from. If omitted, the prospect is removed from all flows they are assigned to. | | `unassignedByUserEmail` | string | No | Email address of the Gong user requesting to remove the prospect from the flow | @@ -919,8 +879,6 @@ Get the Gong Engage flows currently assigned to the given CRM prospects. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to look up | #### Output @@ -952,8 +910,6 @@ Retrieve coaching metrics for a manager from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `managerId` | string | Yes | Gong user ID of the manager | | `workspaceId` | string | Yes | Gong workspace ID | | `fromDate` | string | Yes | Start date in ISO-8601 format | @@ -992,8 +948,6 @@ Ask a natural-language question about a CRM account, deal, contact, or lead. Gon | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | Yes | Gong workspace ID the entity belongs to | | `crmEntityType` | string | Yes | Type of the CRM entity: ACCOUNT, CONTACT, DEAL, or LEAD | | `crmEntityId` | string | Yes | The CRM ID of the entity the question is asked about | @@ -1026,8 +980,6 @@ Generate an AI brief (configured in Gong Agent Studio) for a CRM account, deal, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | Yes | Gong workspace ID the entity belongs to | | `briefName` | string | Yes | Name of the brief to generate, as configured in Gong Agent Studio > AI Briefer | | `crmEntityType` | string | Yes | Type of the CRM entity: ACCOUNT, CONTACT, DEAL, or LEAD | @@ -1063,8 +1015,6 @@ Retrieve Gong log entries of a specific type within a time range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `logType` | string | Yes | Type of logs requested: AccessLog, UserActivityLog, UserCallPlay, ExternallySharedCallAccess, or ExternallySharedCallPlay | | `fromDateTime` | string | Yes | Time from which to retrieve log records, in ISO-8601 format \(e.g., '2024-01-01T00:00:00Z'\) | | `toDateTime` | string | No | Time until which to retrieve log records, in ISO-8601 format. Defaults to the latest available logs when omitted. | @@ -1102,8 +1052,6 @@ Find all references to an email address in Gong (calls, email messages, meetings | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `emailAddress` | string | Yes | Email address to look up | #### Output @@ -1158,8 +1106,6 @@ Find all references to a phone number in Gong (calls, email messages, meetings, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `phoneNumber` | string | Yes | Phone number to look up \(must start with + followed by country code\) | #### Output @@ -1209,8 +1155,6 @@ Erase all Gong data (calls, email messages, leads, contacts) referencing an emai | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `emailAddress` | string | Yes | Email address whose associated data should be permanently erased from Gong | #### Output @@ -1231,8 +1175,6 @@ Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchr | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `phoneNumber` | string | Yes | Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code \(e.g., +14255552671\) | #### Output diff --git a/apps/docs/content/docs/integrations/hubspot.mdx b/apps/docs/content/docs/integrations/hubspot.mdx index c95987b002e..4364ecf19cb 100644 --- a/apps/docs/content/docs/integrations/hubspot.mdx +++ b/apps/docs/content/docs/integrations/hubspot.mdx @@ -13,7 +13,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" [HubSpot](https://www.hubspot.com/) stores CRM contacts, companies, deals, tickets, and related records. Use this integration to read and update those records and trigger workflows from HubSpot events. Follow the [HubSpot setup guide](/integrations/hubspot-setup) to connect your account. {/* MANUAL-CONTENT-END */} -Connection guides: [Private app tokens](/integrations/hubspot-service-account) · [Setup guide](/integrations/hubspot-setup). +Connection guides: [Service keys and private app tokens](/integrations/hubspot-service-account) · [Setup guide](/integrations/hubspot-setup). ## OAuth scopes [#oauth-scopes] diff --git a/apps/docs/content/docs/integrations/intune.mdx b/apps/docs/content/docs/integrations/intune.mdx index fa44bc119c7..fded003b153 100644 --- a/apps/docs/content/docs/integrations/intune.mdx +++ b/apps/docs/content/docs/integrations/intune.mdx @@ -40,7 +40,7 @@ Sim requests these scopes when someone connects a Microsoft Intune account. For ## Use the block [#usage-instructions] -Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account and an active Intune tenant license. Remote actions depend on device platform and the connected administrator’s Intune permissions. +Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account, or a Microsoft app credential with application permissions, and an active Intune tenant license. Remote actions depend on device platform and on the connected administrator’s Intune permissions or the app’s application permissions. ## Actions diff --git a/apps/docs/content/docs/integrations/planetscale.mdx b/apps/docs/content/docs/integrations/planetscale.mdx index f6589c52722..f9a9a93ecbe 100644 --- a/apps/docs/content/docs/integrations/planetscale.mdx +++ b/apps/docs/content/docs/integrations/planetscale.mdx @@ -2,7 +2,7 @@ title: PlanetScale description: Manage PlanetScale databases, branches, backups, and deploy requests integration: - type: "planetscale" + type: "planetscale_v2" color: "#111111" --- @@ -31,8 +31,6 @@ List databases | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `q` | string | No | Search term to filter databases by name | | `page` | number | No | If provided, specifies the page offset of returned results | @@ -74,8 +72,6 @@ Get a database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | @@ -109,8 +105,6 @@ List branches | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | | `q` | string | No | Search branches by name | @@ -156,8 +150,6 @@ Get a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | | `branch` | string | Yes | Branch name from `list_branches`. Example: `main`. | @@ -192,8 +184,6 @@ Create a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `name` | string | Yes | The name of the branch to create | @@ -237,8 +227,6 @@ Delete a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -262,8 +250,6 @@ List backups | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -312,8 +298,6 @@ Get a backup | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `backupId` | string | Yes | The ID for the backup | | `database` | string | Yes | The name of the database the branch belongs to | @@ -348,8 +332,6 @@ Create a backup | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -387,8 +369,6 @@ List deploy requests (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `state` | string | No | Filter by state of the deploy request \(open, closed, deployed\) | @@ -437,8 +417,6 @@ Get a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -475,8 +453,6 @@ Create a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `branch` | string | Yes | The name of the branch the deploy request is created from | @@ -517,8 +493,6 @@ Review a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the deploy request belongs to | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -548,8 +522,6 @@ Queue a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -587,8 +559,6 @@ Close a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | diff --git a/apps/docs/content/docs/integrations/sailpoint.mdx b/apps/docs/content/docs/integrations/sailpoint.mdx index 9d8784bcca8..3c77a34c172 100644 --- a/apps/docs/content/docs/integrations/sailpoint.mdx +++ b/apps/docs/content/docs/integrations/sailpoint.mdx @@ -2,7 +2,7 @@ title: SailPoint description: Govern identities and access in SailPoint Identity Security Cloud integration: - type: "sailpoint" + type: "sailpoint_v2" color: "#FFFFFF" --- @@ -53,9 +53,6 @@ Approve one pending access-request approval. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `approvalId` | string | Yes | Approval ID | | `comment` | string | No | Optional reviewer comment | @@ -78,9 +75,6 @@ Cancel an access request that has not passed approval. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `accountActivityId` | string | Yes | Account activity / identity request ID | | `comment` | string | Yes | Cancellation reason | @@ -103,9 +97,6 @@ Approve or revoke 1-250 review items in an identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | | `decisions` | array | Yes | Array of \{id, decision: APPROVE\|REVOKE, bulk, proposedEndDate?, recommendation?, comments?\} | @@ -144,9 +135,6 @@ Get an access profile by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Access profile ID | #### Output @@ -183,9 +171,6 @@ List entitlements in one access profile. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Access profile ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -255,9 +240,6 @@ Get tenant access-request, request-on-behalf-of, and machine-identity configurat | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | #### Output @@ -303,9 +285,6 @@ List requested-item status records for access requests. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestedFor` | string | No | Identity ID for whom the access was requested | | `requestedBy` | string | No | Identity ID that submitted the access request | | `regardingIdentity` | string | No | Identity ID that is either the requester or the request target | @@ -368,9 +347,6 @@ Get an account from the current /accounts/v1 service by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account ID | #### Output @@ -419,9 +395,6 @@ Get an account activity by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account activity ID | #### Output @@ -456,9 +429,6 @@ List entitlements granted to one account. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account ID | | `limit` | number | No | Maximum records for this page \(0-250; default 250\) | | `offset` | number | No | Zero-based record offset \(default 0\) | @@ -526,9 +496,6 @@ Resolve eligible source accounts before submitting a machine or multi-account ac | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestType` | string | No | GRANT_ACCESS \(default\), REVOKE_ACCESS, or MODIFY_ACCESS | | `requestedFor` | array | No | Human identity IDs for the flat request shape | | `requestedItems` | array | No | Flat human request items | @@ -575,9 +542,6 @@ Get a certification campaign by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Campaign ID | | `detail` | string | No | SLIM or FULL | @@ -622,9 +586,6 @@ Get an identity certification by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | #### Output @@ -662,9 +623,6 @@ Get an entitlement by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Entitlement ID | #### Output @@ -729,9 +687,6 @@ Get grant, revocation, duration, approval, and form settings for an entitlement. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Entitlement ID | #### Output @@ -768,9 +723,6 @@ Get an identity from the current /identities/v1 service by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Identity ID | #### Output @@ -804,9 +756,6 @@ Get a role by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Role ID | #### Output @@ -847,9 +796,6 @@ List entitlements in one role using the current non-experimental roles service. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Role ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -919,9 +865,6 @@ Get an identity source by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Source ID | #### Output @@ -973,9 +916,6 @@ Get the current status of a SailPoint background task by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Task ID | #### Output @@ -1031,9 +971,6 @@ List access profiles with current visibility and segmentation controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1079,9 +1016,6 @@ List provisioning activities with identity, filter, sort, and page controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestedFor` | string | No | Target identity ID or 'me'; mutually exclusive with regardingIdentity | | `requestedBy` | string | No | Requester identity ID or 'me'; mutually exclusive with regardingIdentity | | `regardingIdentity` | string | No | Requester-or-target identity ID or 'me'; excludes requestedFor/requestedBy | @@ -1125,9 +1059,6 @@ List accounts with documented filtering, sorting, detail, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `detailLevel` | string | No | SLIM or FULL \(default FULL\) | @@ -1183,9 +1114,6 @@ List certification campaigns with detail, filtering, sorting, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `detail` | string | No | SLIM \(default\) or FULL | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1236,9 +1164,6 @@ List access-review items in one identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1276,9 +1201,6 @@ List identity certifications assigned to a reviewer. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `reviewerIdentity` | string | No | Reviewer identity ID or 'me' | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1323,9 +1245,6 @@ List entitlements with current segmentation, cursor, filter, and page controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `segmentedForIdentity` | string | No | Identity ID whose visible segments restrict the results | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1400,9 +1319,6 @@ List identities with documented filtering, sorting, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `defaultFilter` | string | No | CORRELATED_ONLY \(default\) or NONE | @@ -1443,9 +1359,6 @@ List tagged entitlement references held by one identity. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Identity ID | | `limit` | number | No | Maximum records for this page \(0-250; default 250\) | | `offset` | number | No | Zero-based record offset \(default 0\) | @@ -1473,9 +1386,6 @@ List pending access-request approvals visible to the caller. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `ownerId` | string | No | Approval owner identity ID or 'me'; admins may omit it for all approvals | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1533,9 +1443,6 @@ List roles with current visibility, segmentation, filtering, and pagination cont | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1585,9 +1492,6 @@ List identity sources with visibility, filtering, sorting, and pagination contro | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | @@ -1647,9 +1551,6 @@ Start account aggregation for a source, optionally using a CSV file. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `sourceId` | string | Yes | Source ID | | `file` | file | No | Delimited-file source account CSV | | `disableOptimization` | boolean | No | Reprocess every account instead of using optimized aggregation | @@ -1694,9 +1595,6 @@ Start entitlement aggregation for a source, optionally using a CSV file. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `sourceId` | string | Yes | Source ID | | `file` | file | No | Delimited-file source entitlement CSV | @@ -1727,9 +1625,6 @@ Reject one pending access-request approval with a reviewer comment. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `approvalId` | string | Yes | Approval ID | | `comment` | string | Yes | Reviewer rejection comment | @@ -1752,9 +1647,6 @@ Submit a current human or machine identity access request. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestType` | string | No | GRANT_ACCESS \(default\), REVOKE_ACCESS, or MODIFY_ACCESS | | `requestedFor` | array | No | Human identity IDs for the flat request shape | | `requestedItems` | array | No | Flat human request items | @@ -1794,9 +1686,6 @@ Search current SailPoint indices with every documented search query mode. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1837,9 +1726,6 @@ Run an Elasticsearch DSL or SailPoint aggregation over current search indices. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1880,9 +1766,6 @@ Count documents matching a complete SailPoint search body. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1918,9 +1801,6 @@ Sign off a completed identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | #### Output diff --git a/apps/docs/content/docs/integrations/servicenow.mdx b/apps/docs/content/docs/integrations/servicenow.mdx index 91f21ff398e..e2b7066107f 100644 --- a/apps/docs/content/docs/integrations/servicenow.mdx +++ b/apps/docs/content/docs/integrations/servicenow.mdx @@ -2,7 +2,7 @@ title: ServiceNow description: Create, read, update, and delete ServiceNow records integration: - type: "servicenow_v2" + type: "servicenow_v3" color: "#032D42" --- @@ -29,9 +29,6 @@ Create a new record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | | `fields` | json | Yes | Fields to set on the record as JSON object \(e.g., \{"short_description": "Issue title", "priority": "1"\}\) | @@ -54,9 +51,6 @@ Read records from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | No | Specific record sys_id \(e.g., 6816f79cc0a8016401c5a33be04be441\) | | `number` | string | No | Record number \(e.g., INC0010001\) | @@ -85,9 +79,6 @@ Update an existing record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | Yes | Record sys_id to update \(e.g., 6816f79cc0a8016401c5a33be04be441\) | | `fields` | json | Yes | Fields to update as JSON object \(e.g., \{"state": "2", "priority": "1"\}\) | @@ -111,9 +102,6 @@ Delete a record from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | Yes | Record sys_id to delete \(e.g., 6816f79cc0a8016401c5a33be04be441\) | @@ -136,9 +124,6 @@ Compute aggregate statistics (count, sum, average, min, max, group by) over a Se | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, change_request, task\) | | `query` | string | No | Encoded query string to filter records before aggregating \(e.g., "active=true"\) | | `count` | boolean | No | Return the count of matching records | @@ -170,9 +155,6 @@ List the attachments on a ServiceNow record | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table that owns the record \(e.g., incident, change_request\) | | `recordSysId` | string | Yes | sys_id of the record whose attachments should be listed | | `limit` | number | No | Maximum number of attachments to return | @@ -201,9 +183,6 @@ Download an attachment file from ServiceNow by its sys_id | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `attachmentSysId` | string | Yes | sys_id of the attachment to download \(from List Attachments\) | #### Output @@ -224,9 +203,6 @@ Attach a file to a ServiceNow record | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table that owns the record \(e.g., incident, change_request\) | | `recordSysId` | string | Yes | sys_id of the record to attach the file to | | `fileName` | string | Yes | Name to give the uploaded file \(e.g., logs.txt\) | @@ -251,9 +227,6 @@ Create an incident in ServiceNow. Reference fields (caller, assignment group, as | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `shortDescription` | string | Yes | Short description — the one-line summary of the incident. | | `description` | string | No | Detailed description of the incident. | | `callerId` | string | No | Caller \(caller_id\) — sys_id of the sys_user who reported the incident. Use Find ServiceNow User to resolve an email address to a sys_id. | @@ -319,9 +292,6 @@ Retrieve a single ServiceNow incident by number (e.g., INC0010001) or sys_id. Re | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -371,9 +341,6 @@ Search ServiceNow incidents by state, priority, assignment, caller, or text. All | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to match against the incident short description using the ServiceNow LIKE operator, which matches anywhere in the field. | | `state` | string | No | Incident state coded value. Base system: 1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed, 8=Canceled. | | `priority` | string | No | Priority coded value 1-5 \(1 Critical … 5 Planning\). | @@ -431,9 +398,6 @@ Update fields on an existing ServiceNow incident. Only the fields you supply are | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `shortDescription` | string | No | New short description. | | `description` | string | No | New detailed description. | @@ -497,9 +461,6 @@ Move a ServiceNow incident to Resolved (state 6) with a resolution code and reso | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `closeCode` | string | Yes | Resolution code \(close_code\). This is a choice field whose values are configured per instance — read the choice list on your incident table and pass one of its values. | | `closeNotes` | string | Yes | Resolution notes \(close_notes\) documenting how the incident was resolved. | @@ -553,9 +514,6 @@ Move a ServiceNow incident to Closed (state 7) with a resolution code and resolu | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `closeCode` | string | Yes | Resolution code \(close_code\). This is a choice field whose values are configured per instance — read the choice list on your incident table and pass one of its values. | | `closeNotes` | string | Yes | Resolution notes \(close_notes\) documenting how the incident was resolved. | @@ -609,9 +567,6 @@ Append an internal work note or a customer-visible additional comment to a Servi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `comment` | string | Yes | Text to append to the journal field. | | `commentField` | string | No | Which journal field to write to: "work_notes" for an internal note \(default\) or "comments" for a customer-visible additional comment. | @@ -663,9 +618,6 @@ Create a change request in ServiceNow. Reference fields (assignment group, assig | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `shortDescription` | string | Yes | Short description — the one-line summary of the change. | | `description` | string | No | Detailed description of the change. | | `type` | string | No | Change type: "normal", "standard", or "emergency". | @@ -732,9 +684,6 @@ Retrieve a single ServiceNow change request by number (e.g., CHG0030001) or sys_ | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -784,9 +733,6 @@ Search ServiceNow change requests by state, type, risk, assignment, or text. All | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to match against the change short description using the ServiceNow LIKE operator, which matches anywhere in the field. | | `state` | string | No | Change state coded value. Base system: -5=New, -4=Assess, -3=Authorize, -2=Scheduled, -1=Implement, 0=Review, 3=Closed, 4=Canceled. | | `type` | string | No | Change type: "normal", "standard", or "emergency". | @@ -844,9 +790,6 @@ Update fields on an existing ServiceNow change request. Only the fields you supp | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `shortDescription` | string | No | New short description. | | `description` | string | No | New detailed description. | @@ -910,9 +853,6 @@ Move a ServiceNow change request to another state. Base-system change model stat | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `state` | string | Yes | Target state coded value: -5 \(New\), -4 \(Assess\), -3 \(Authorize\), -2 \(Scheduled\), -1 \(Implement\), 0 \(Review\), 3 \(Closed\), or 4 \(Canceled\). | | `closeCode` | string | No | Close code, required when moving to Closed \(3\): "successful", "successful_issues", or "unsuccessful". | @@ -967,9 +907,6 @@ List the change tasks belonging to a ServiceNow change request, via the Change M | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `changeSysId` | string | Yes | sys_id of the change request whose tasks should be listed. | | `query` | string | No | ServiceNow encoded query used to filter the tasks \(e.g., "active=true^ORDERBYnumber"\). | | `order` | string | No | Field to sort the returned tasks by. | @@ -996,9 +933,6 @@ Read the states a ServiceNow change request can actually move to next, with the | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `changeSysId` | string | Yes | sys_id of the change request. Use Get ServiceNow Change Request to resolve a CHG number to its sys_id. | #### Output @@ -1033,9 +967,6 @@ Browse or search the ServiceNow service catalog. Returns each item with its sys_ | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to search for in the catalog items \(e.g., "iPhone"\). | | `catalogSysId` | string | No | Restrict results to a specific catalog, by catalog sys_id. | | `categorySysId` | string | No | Restrict results to a specific category, by category sys_id. | @@ -1079,9 +1010,6 @@ Submit a service catalog request for a catalog item using the Service Catalog AP | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `catalogItemSysId` | string | Yes | sys_id of the catalog item to order. Use List ServiceNow Catalog Items to find it. | | `quantity` | number | No | Quantity to order. Must not be negative. Defaults to 1. | | `requestedFor` | string | No | sys_id of the sys_user the item is ordered for. Ordering on behalf of another user is governed by the glide.sc.req_for.roles instance properties. | @@ -1109,9 +1037,6 @@ List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] ta | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `requestSysId` | string | No | sys_id of the parent request \(sc_request\) whose items should be listed. | | `catalogItemSysId` | string | No | sys_id of the catalog item \(cat_item\) to filter by. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) requested items. | @@ -1165,9 +1090,6 @@ Retrieve a single ServiceNow requested item (RITM) by number (e.g., RITM0010001) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -1217,9 +1139,6 @@ List approval records from the ServiceNow Approval [sysapproval_approver] table. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `approverSysId` | string | No | sys_id of the approver \(sys_user\) whose approvals should be listed. Use Find ServiceNow User to resolve an email address to a sys_id. | | `state` | string | No | Approval state: "requested" \(pending, the default\), "approved", or "rejected". Pass an empty string with a custom query to list every state. | | `approvalFor` | string | No | sys_id of the record being approved, matched against the sysapproval reference field. | @@ -1273,9 +1192,6 @@ Approve or reject a ServiceNow approval record by setting its state on the Appro | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `approvalSysId` | string | Yes | sys_id of the approval record on the sysapproval_approver table. Use List ServiceNow Approvals to find it. | | `decision` | string | Yes | Decision to record: "approved" or "rejected". | | `comments` | string | No | Comment to record alongside the decision. This is a journal field. | @@ -1327,9 +1243,6 @@ Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciClass` | string | No | CMDB class \(table\) to search, e.g., cmdb_ci_linux_server or cmdb_ci_app_server. Defaults to cmdb_ci, which covers every CI class. | | `name` | string | No | Text to match against the CI name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `operationalStatus` | string | No | Operational status coded value \(operational_status\). The choice list is configured per instance. | @@ -1383,9 +1296,6 @@ Retrieve a configuration item and its CMDB relationships through the CMDB Instan | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciClass` | string | Yes | CMDB class \(table\) the CI belongs to, e.g., cmdb_ci_linux_server. Read it from the sys_class_name field returned by Search ServiceNow Configuration Items. | | `sysId` | string | Yes | sys_id of the configuration item to retrieve. | @@ -1418,9 +1328,6 @@ List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciSysId` | string | Yes | sys_id of the configuration item whose relationships should be listed. | | `direction` | string | No | Which side of the relationship the CI sits on: "parent", "child", or "both" \(default\). "both" matches rows where the CI is either the parent or the child. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | @@ -1473,9 +1380,6 @@ Search ServiceNow knowledge base articles through the Knowledge Management API. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `query` | string | No | Text to search for across knowledge articles. | | `filter` | string | No | Encoded query used to filter the results, against the Knowledge \[kb_knowledge\] table \(e.g., "workflow_state=published"\). | | `knowledgeBaseSysIds` | string | No | Comma-separated knowledge base sys_ids \(from kb_knowledge_base\) to restrict results to. | @@ -1513,9 +1417,6 @@ Retrieve the full content of a ServiceNow knowledge article by sys_id or KB numb | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `articleId` | string | Yes | sys_id or KB number of the knowledge article \(e.g., KB0000011\). | | `fields` | string | No | Comma-separated kb_knowledge fields to include alongside the article content \(e.g., workflow_state,author\). | | `language` | string | No | Two-letter ISO 639-1 language code. Only applies when the article is addressed by KB number and a translation exists. | @@ -1548,9 +1449,6 @@ Look up ServiceNow users by email, user name, or display name. Use this to resol | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `email` | string | No | Exact email address to match. | | `userName` | string | No | Exact user name \(user_name\) to match. | | `name` | string | No | Text to match against the display name using the ServiceNow LIKE operator, which matches anywhere in the field. | @@ -1605,9 +1503,6 @@ List the members of a ServiceNow group from the Group Member [sys_user_grmember] | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `groupSysId` | string | No | sys_id of the sys_user_group whose members should be listed. | | `groupName` | string | No | Exact group name, resolved against the referenced group record. Provide this or the group sys_id. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | diff --git a/apps/docs/content/docs/integrations/zendesk.mdx b/apps/docs/content/docs/integrations/zendesk.mdx index aacb7f4a99b..883b298cc3e 100644 --- a/apps/docs/content/docs/integrations/zendesk.mdx +++ b/apps/docs/content/docs/integrations/zendesk.mdx @@ -2,7 +2,7 @@ title: Zendesk description: Manage support tickets, users, and organizations in Zendesk integration: - type: "zendesk" + type: "zendesk_v2" color: "#03363D" --- @@ -38,9 +38,6 @@ Retrieve a list of tickets from Zendesk with optional filtering | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `status` | string | No | Filter by status: "new", "open", "pending", "hold", "solved", or "closed" | | `priority` | string | No | Filter by priority: "low", "normal", "high", or "urgent" | | `type` | string | No | Filter by type: "problem", "incident", "question", or "task" | @@ -127,9 +124,6 @@ Get a single ticket by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -203,9 +197,6 @@ Create a new ticket in Zendesk with support for custom fields | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `subject` | string | No | Ticket subject \(optional - will be auto-generated if not provided\) | | `description` | string | Yes | Ticket description text \(first comment\) | | `priority` | string | No | Priority: "low", "normal", "high", or "urgent" | @@ -288,9 +279,6 @@ Create multiple tickets in Zendesk at once (max 100) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `tickets` | string | Yes | JSON array of ticket objects to create \(max 100\). Each ticket should have subject and comment properties \(e.g., \[\{"subject": "Issue 1", "comment": \{"body": "Description"\}\}\]\) | #### Output @@ -326,9 +314,6 @@ Update an existing ticket in Zendesk with support for custom fields | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to update as a numeric string \(e.g., "12345"\) | | `subject` | string | No | New ticket subject text | | `comment` | string | No | Comment text to add to the ticket | @@ -411,9 +396,6 @@ Update multiple tickets in Zendesk at once (max 100) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketIds` | string | Yes | Comma-separated ticket IDs to update \(max 100, e.g., "111, 222, 333"\) | | `status` | string | No | New status for all tickets: "new", "open", "pending", "hold", "solved", or "closed" | | `priority` | string | No | New priority for all tickets: "low", "normal", "high", or "urgent" | @@ -454,9 +436,6 @@ Delete a ticket from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -478,9 +457,6 @@ Merge multiple tickets into a target ticket | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `targetTicketId` | string | Yes | Target ticket ID as a numeric string \(tickets will be merged into this one, e.g., "12345"\) | | `sourceTicketIds` | string | Yes | Comma-separated source ticket IDs to merge \(e.g., "111, 222, 333"\) | | `targetComment` | string | No | Comment text to add to target ticket after merge | @@ -519,9 +495,6 @@ Retrieve a list of users from Zendesk with optional filtering | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `role` | string | No | Filter by role: "end-user", "agent", or "admin" | | `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -593,9 +566,6 @@ Get a single user by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -658,9 +628,6 @@ Get the currently authenticated user from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | #### Output @@ -722,9 +689,6 @@ Search for users in Zendesk using a query string | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | No | Search query string \(e.g., user name or email\) | | `externalId` | string | No | External ID to search by \(your system identifier\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -796,9 +760,6 @@ Create a new user in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | User full name \(e.g., "John Smith"\) | | `userEmail` | string | No | User email address \(e.g., "john@example.com"\) | | `role` | string | No | User role: "end-user", "agent", or "admin" | @@ -868,9 +829,6 @@ Create multiple users in Zendesk using bulk import | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `users` | string | Yes | JSON array of user objects to create \(e.g., \[\{"name": "User1", "email": "user1@example.com"\}\]\) | #### Output @@ -906,9 +864,6 @@ Update an existing user in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to update as a numeric string \(e.g., "12345"\) | | `name` | string | No | New user full name \(e.g., "John Smith"\) | | `userEmail` | string | No | New user email address \(e.g., "john@example.com"\) | @@ -979,9 +934,6 @@ Update multiple users in Zendesk using bulk update | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `users` | string | Yes | JSON array of user objects to update, each must include id field \(e.g., \[\{"id": "123", "name": "New Name"\}\]\) | #### Output @@ -1017,9 +969,6 @@ Delete a user from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -1041,9 +990,6 @@ Retrieve a list of organizations from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results | @@ -1086,9 +1032,6 @@ Get a single organization by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -1124,9 +1067,6 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `page` | string | No | Page number for pagination \(1-based\) | @@ -1170,9 +1110,6 @@ Create a new organization in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | Organization name \(e.g., "Acme Corporation"\) | | `domainNames` | string | No | Comma-separated domain names \(e.g., "acme.com, acme.org"\) | | `details` | string | No | Organization details text | @@ -1213,9 +1150,6 @@ Create multiple organizations in Zendesk using bulk import | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizations` | string | Yes | JSON array of organization objects to create \(e.g., \[\{"name": "Org1"\}, \{"name": "Org2"\}\]\) | #### Output @@ -1251,9 +1185,6 @@ Update an existing organization in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to update as a numeric string \(e.g., "12345"\) | | `name` | string | No | New organization name \(e.g., "Acme Corporation"\) | | `domainNames` | string | No | Comma-separated domain names \(e.g., "acme.com, acme.org"\) | @@ -1295,9 +1226,6 @@ Delete an organization from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -1319,9 +1247,6 @@ Unified search across tickets, users, and organizations in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | | `filterType` | string | Yes | Resource type to search for: "ticket", "user", "organization", or "group" | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -1352,9 +1277,6 @@ Count the number of search results matching a query in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | #### Output diff --git a/apps/docs/content/docs/integrations/zoominfo.mdx b/apps/docs/content/docs/integrations/zoominfo.mdx index 0813a8f74e1..39481c9a471 100644 --- a/apps/docs/content/docs/integrations/zoominfo.mdx +++ b/apps/docs/content/docs/integrations/zoominfo.mdx @@ -2,7 +2,7 @@ title: ZoomInfo description: Search and enrich B2B company and contact data with ZoomInfo. integration: - type: "zoominfo" + type: "zoominfo_v2" color: "#EA1B15" --- @@ -35,8 +35,6 @@ Search the ZoomInfo company database by name, industry, location, and size. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `companyName` | string | No | Company name to search for | | `companyWebsite` | string | No | Company website \(comma-separated for multiple\) | | `companyTicker` | string | No | Stock ticker symbols — JSON array, comma-separated list, or single ticker. Sent to the API as an array. | @@ -75,8 +73,6 @@ Search ZoomInfo for contacts (people) by name, job title, company, and other fil | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `firstName` | string | No | First name | | `lastName` | string | No | Last name | | `fullName` | string | No | Full name | @@ -115,8 +111,6 @@ Enrich up to 25 companies in one request with detailed firmographics, industry, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `matchCompanyInput` | string | Yes | JSON array \(1-25 items\) of company matching criteria, e.g. \[\{"companyName":"Acme","companyWebsite":"acme.com"\}\] | | `outputFields` | string | No | JSON array or comma-separated list of fields to return \(e.g. \["id","name","website","revenue","employeeCount"\]\). Defaults to a standard firmographic set if omitted. | @@ -138,8 +132,6 @@ Enrich up to 25 contacts in one request with verified emails, phone numbers, job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `matchPersonInput` | string | Yes | JSON array \(1-25 items\) of contact matching criteria, e.g. \[\{"firstName":"Jane","lastName":"Doe","companyName":"Acme"\}\] | | `outputFields` | string | No | JSON array or comma-separated list of fields to return \(e.g. \["id","firstName","email","phone","jobTitle"\]\). Defaults to a standard contact set if omitted. | | `requiredFields` | string | No | JSON array or comma-separated list of fields that must exist in results \(e.g. \["email"\]\) | @@ -162,8 +154,6 @@ Search for companies showing intent signals on specific topics. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `topics` | string | Yes | Up to 50 intent topics as JSON array or comma-separated list \(e.g. \["CRM Software","Marketing Automation"\]\) | | `signalStartDate` | string | No | Earliest signal date \(YYYY-MM-DD\) | | `signalEndDate` | string | No | Latest signal date \(YYYY-MM-DD\) | @@ -199,8 +189,6 @@ Search ZoomInfo news articles by category, URL, or date range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `categories` | string | No | News categories as JSON array or comma-separated list | | `url` | string | No | News source URLs as JSON array or comma-separated list | | `pageDateMin` | string | No | Earliest publish date \(YYYY-MM-DD\) | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index d18c6fee64d..c8c15ead5ab 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -19739,6 +19739,10 @@ "planetscale.branches", "planetscale.backups", "planetscale.deployRequests", + "planetscale.savedDatabases", + "planetscale.savedBranches", + "planetscale.savedBackups", + "planetscale.savedDeployRequests", "cloudwatch.logGroups", "cloudwatch.logStreams", "cloudwatch.savedLogGroups", @@ -19938,6 +19942,10 @@ "planetscale.branches", "planetscale.backups", "planetscale.deployRequests", + "planetscale.savedDatabases", + "planetscale.savedBranches", + "planetscale.savedBackups", + "planetscale.savedDeployRequests", "cloudwatch.logGroups", "cloudwatch.logStreams", "cloudwatch.savedLogGroups", diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts index 4eec2893e1f..763ae48f0a0 100644 --- a/apps/sim/lib/block-metadata/names.generated.ts +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -52,12 +52,12 @@ export const BLOCK_NAMES: Readonly> = { convex_v2: 'Convex', credential: 'Credential', credential_group: 'Connected Accounts (Legacy)', - crowdstrike: 'CrowdStrike', + crowdstrike_v2: 'CrowdStrike', crunchbase_v2: 'Crunchbase', cursor_v3: 'Cursor', dagster: 'Dagster', databricks_v2: 'Databricks', - datadog: 'Datadog', + datadog_v2: 'Datadog', datagma: 'Datagma', daytona_v2: 'Daytona', deployments: 'Deployments', @@ -98,7 +98,7 @@ export const BLOCK_NAMES: Readonly> = { github_v3: 'GitHub', gitlab_v2: 'GitLab', gmail_v2: 'Gmail', - gong: 'Gong', + gong_v2: 'Gong', google_ads: 'Google Ads', google_appsheet: 'Google AppSheet', google_bigquery: 'Google BigQuery', @@ -216,7 +216,7 @@ export const BLOCK_NAMES: Readonly> = { pipedrive: 'Pipedrive', pitchbook: 'PitchBook', plane_v2: 'Plane', - planetscale: 'PlanetScale', + planetscale_v2: 'PlanetScale', polymarket: 'Polymarket', postgresql: 'PostgreSQL', posthog: 'PostHog', @@ -245,7 +245,7 @@ export const BLOCK_NAMES: Readonly> = { router_v2: 'Router', rss: 'RSS Feed', s3_v2: 'S3', - sailpoint: 'SailPoint', + sailpoint_v2: 'SailPoint', salesforce: 'Salesforce', sap_concur: 'SAP Concur', sap_s4hana: 'SAP S4HANA', @@ -257,7 +257,7 @@ export const BLOCK_NAMES: Readonly> = { sendgrid_v2: 'SendGrid', sentry_v2: 'Sentry', serper: 'Serper', - servicenow_v2: 'ServiceNow', + servicenow_v3: 'ServiceNow', ses_v2: 'AWS SES', sftp_v2: 'SFTP', sharepoint_v2: 'SharePoint', @@ -322,10 +322,10 @@ export const BLOCK_NAMES: Readonly> = { x: 'X', youcom_v2: 'You.com', youtube_v2: 'YouTube', - zendesk: 'Zendesk', + zendesk_v2: 'Zendesk', zep_v2: 'Zep', zerobounce: 'ZeroBounce', zoho_desk: 'Zoho Desk', zoom: 'Zoom', - zoominfo: 'ZoomInfo', + zoominfo_v2: 'ZoomInfo', } diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index c9665b70362..b66173fab2e 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -368,6 +368,7 @@ export const blockTypeToIconMap: Record = { convex: ConvexIcon, convex_v2: ConvexIcon, crowdstrike: CrowdStrikeIcon, + crowdstrike_v2: CrowdStrikeIcon, crunchbase: CrunchbaseIcon, crunchbase_v2: CrunchbaseIcon, cursor: CursorIcon, @@ -376,6 +377,7 @@ export const blockTypeToIconMap: Record = { databricks: DatabricksIcon, databricks_v2: DatabricksIcon, datadog: DatadogIcon, + datadog_v2: DatadogIcon, datagma: DatagmaIcon, daytona: DaytonaIcon, daytona_v2: DaytonaIcon, @@ -435,6 +437,7 @@ export const blockTypeToIconMap: Record = { gmail: GmailIcon, gmail_v2: GmailIcon, gong: GongIcon, + gong_v2: GongIcon, google_ads: GoogleAdsIcon, google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, @@ -591,6 +594,7 @@ export const blockTypeToIconMap: Record = { plane: PlaneIcon, plane_v2: PlaneIcon, planetscale: PlanetScaleIcon, + planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, @@ -630,6 +634,7 @@ export const blockTypeToIconMap: Record = { s3: S3Icon, s3_v2: S3Icon, sailpoint: SailPointIcon, + sailpoint_v2: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, @@ -643,7 +648,7 @@ export const blockTypeToIconMap: Record = { sentry_v2: SentryIcon, serper: SerperIcon, servicenow: ServiceNowIcon, - servicenow_v2: ServiceNowIcon, + servicenow_v3: ServiceNowIcon, ses: SESIcon, ses_v2: SESIcon, sftp: SftpIcon, @@ -732,10 +737,12 @@ export const blockTypeToIconMap: Record = { youtube: YouTubeIcon, youtube_v2: YouTubeIcon, zendesk: ZendeskIcon, + zendesk_v2: ZendeskIcon, zep: ZepIcon, zep_v2: ZepIcon, zerobounce: ZeroBounceIcon, zoho_desk: ZohoDeskIcon, zoom: ZoomIcon, zoominfo: ZoomInfoIcon, + zoominfo_v2: ZoomInfoIcon, } diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts index fbd8d5f4198..5fdfd4f0989 100644 --- a/apps/sim/lib/permission-groups/block-successors.generated.ts +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -37,10 +37,12 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { codepipeline: 'codepipeline_v2', confluence: 'confluence_v2', convex: 'convex_v2', + crowdstrike: 'crowdstrike_v2', crunchbase: 'crunchbase_v2', cursor: 'cursor_v3', cursor_v2: 'cursor_v3', databricks: 'databricks_v2', + datadog: 'datadog_v2', daytona: 'daytona_v2', devin: 'devin_v2', discord: 'discord_v2', @@ -68,6 +70,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { github_v2: 'github_v3', gitlab: 'gitlab_v2', gmail: 'gmail_v2', + gong: 'gong_v2', google_calendar: 'google_calendar_v2', google_sheets: 'google_sheets_v2', google_slides: 'google_slides_v2', @@ -118,6 +121,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { pagerduty: 'pagerduty_v2', persona: 'persona_v2', plane: 'plane_v2', + planetscale: 'planetscale_v2', profound: 'profound_v2', pulse: 'pulse_v3', pulse_v2: 'pulse_v3', @@ -134,10 +138,12 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { rootly: 'rootly_v2', router: 'router_v2', s3: 's3_v2', + sailpoint: 'sailpoint_v2', secrets_manager: 'secrets_manager_v2', sendgrid: 'sendgrid_v2', sentry: 'sentry_v2', - servicenow: 'servicenow_v2', + servicenow: 'servicenow_v3', + servicenow_v2: 'servicenow_v3', ses: 'ses_v2', sftp: 'sftp_v2', sharepoint: 'sharepoint_v2', @@ -176,5 +182,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { workflow: 'workflow_input', youcom: 'youcom_v2', youtube: 'youtube_v2', + zendesk: 'zendesk_v2', zep: 'zep_v2', + zoominfo: 'zoominfo_v2', } diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index c9a1a8f9daa..a82855e7ac4 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Start an APIFY Actor run and poll it within the same workflow step until completion or the polling deadline","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ] }"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Remove personally identifiable information from an Ashby candidate while retaining the candidate record and its applications. This operation does not delete the candidate. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,