diff --git a/apps/docs/content/docs/en/integrations/box-service-account.mdx b/apps/docs/content/docs/en/integrations/box-service-account.mdx
new file mode 100644
index 00000000000..2e737045d13
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/box-service-account.mdx
@@ -0,0 +1,131 @@
+---
+title: Box Service Accounts
+description: Set up a Box Platform app with Client Credentials Grant so your workflows can use Box through its Service Account
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Box Platform apps with **Server Authentication (with Client Credentials Grant)** let your workflows authenticate to Box as the app's own **Service Account** instead of through a person's OAuth login. You create the app once, a Box admin authorizes it for the enterprise, and Sim mints short-lived access tokens from the app's credentials on demand — no user consent to expire, and access that's controlled entirely by which folders the Service Account is invited into.
+
+This is the recommended way to use Box in production workflows: nothing depends on a user staying logged in, the app's scopes are explicit, and the Service Account's reach is auditable folder by folder.
+
+## Prerequisites
+
+Anyone with Developer Console access can create the app, but a Box **admin or co-admin** must authorize it in the Admin Console before it can mint tokens. Free developer accounts are authorized automatically.
+
+## Setting Up the Platform App
+
+### 1. Create the App
+
+
+
+ Go to the [Box Developer Console](https://app.box.com/developers/console), open **My Apps**, click **Create Platform App**, and choose **Server Authentication (with Client Credentials Grant)**
+
+ {/* TODO(screenshot): Box Developer Console Create Platform App dialog with Server Authentication (with Client Credentials Grant) selected */}
+
+
+ On the **Configuration** tab, set the **App Access Level**. **App Access Only** (the default) is sufficient — choose **App + Enterprise Access** only if the Service Account should also reach existing managed users' content via admin APIs
+
+
+ Under **Application Scopes**, check **Read all files and folders stored in Box** and **Write all files and folders stored in Box**. If you'll use Sim's Box Sign operations, also check **Manage signature requests**
+
+ {/* TODO(screenshot): Application Scopes section with read, write, and signature scopes checked */}
+
+
+ Copy the **Client ID** and **Client secret** from **Configuration** → **OAuth 2.0 Credentials** (revealing the secret may prompt for two-factor verification)
+
+ {/* TODO(screenshot): OAuth 2.0 Credentials panel showing the Client ID and the client secret reveal */}
+
+
+ Copy the **Enterprise ID** — a numeric value. In the Developer Console, click your account icon in the top right and choose **Copy Enterprise ID**; a Box admin can also find it in **Admin Console** → **Account & Billing** → **Account Information**
+
+
+
+### 2. Authorize the App in the Admin Console
+
+Token requests fail with `unauthorized_client` ("This app is not authorized by the enterprise admin") until a Box admin authorizes the app:
+
+
+
+ A Box admin or co-admin opens **Admin Console** → **Apps** → **Platform Apps Manager** (in some tenants this appears as **Platform** → **Platform Apps**)
+
+
+ Click **Add App** and enter the app's **Client ID**
+
+ {/* TODO(screenshot): Platform Apps Manager Add App dialog with the Client ID entered */}
+
+
+
+Alternatively, the developer can click **Review and Submit** on the app's **Authorization** tab in the Developer Console to send the request to the admin.
+
+
+**Authorization is a snapshot.** If you later change the app's scopes or access level — for example, adding the signature scope — the admin must **re-authorize** the app in the same Platform Apps Manager section before the change takes effect. Until then, token minting keeps succeeding but the new scopes don't apply, which surfaces as persistent `403` errors on tools despite correct-looking configuration.
+
+
+### 3. Give the Service Account Access to Folders
+
+The Service Account is a brand-new Box user — its email looks like `AutomationUser_AppServiceID_RandomString@boxdevedition.com` and is shown on the app's **General Settings** tab. Its folder tree starts **empty**: a fully valid credential sees zero items and gets `404`s on real files and folders until you grant it access.
+
+
+
+ In Box, invite the Service Account's `@boxdevedition.com` email as a **collaborator** on each folder your workflows should work with — use the **Editor** role for read/write access
+
+ {/* TODO(screenshot): Box folder collaboration dialog inviting the AutomationUser email as Editor */}
+
+
+ Verify by running Sim's Box **List Folder Items** on folder ID `0` — the collaborated folders should appear
+
+
+
+
+Community reports indicate the Service Account can't be collaborated into a user's root folder itself — invite it into individual folders instead.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open **Integrations** from your workspace sidebar
+
+
+ Search for "Box" and open it, then click **Add to Sim** and choose **Add service account**
+
+ {/* TODO(screenshot): Box integration page with the Add service account connect option */}
+
+
+ In the **Add Box service account** dialog, paste the **Client ID**, **Client secret**, and **Enterprise ID** (numeric), and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Box service account dialog with all three fields filled in */}
+
+
+ Click **Add service account**. Sim verifies the credentials by minting a real access token from Box — if it fails, the error tells you whether Box rejected the credentials or couldn't be reached. A rejection usually means bad credentials, an app the admin hasn't authorized yet, or values that don't all belong to the same app and enterprise.
+
+
+
+## Using the Service Account in Workflows
+
+Add a Box block to your workflow. In the credential dropdown, your Box service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Box block in a workflow with the Box service account selected as the credential */}
+
+The block calls `api.box.com` with a freshly minted access token — the same requests as the OAuth flow, so every Box operation works, subject to the app's scopes and the folders the Service Account can see.
+
+
+Sim's Box block includes Box Sign operations. These need the **Manage signature requests** scope on the app *and* Box Sign enabled on your enterprise's plan — without either, signature operations fail while file and folder operations keep working.
+
+
+## Token Behavior
+
+Access tokens minted from the app are short-lived (typically one hour) and there is no refresh token — Sim simply mints a new token when one is needed. The stored Client ID, secret, and Enterprise ID stay valid until you rotate the secret in the Developer Console or the admin removes the app's authorization. If you rotate the secret, update the credential in Sim right away — reconnecting asks you to re-enter all three values.
+
+
diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json
index 248715d9577..d30d080234f 100644
--- a/apps/docs/content/docs/en/integrations/meta.json
+++ b/apps/docs/content/docs/en/integrations/meta.json
@@ -1,13 +1,32 @@
{
"pages": [
"index",
+ "---Service Accounts & API Keys---",
+ "airtable-service-account",
+ "asana-service-account",
+ "atlassian-service-account",
+ "attio-service-account",
+ "box-service-account",
+ "calcom-service-account",
+ "google-service-account",
+ "hubspot-service-account",
+ "linear-service-account",
+ "monday-service-account",
+ "notion-service-account",
+ "pipedrive-service-account",
+ "salesforce-service-account",
+ "shopify-service-account",
+ "trello-service-account",
+ "wealthbox-service-account",
+ "webflow-service-account",
+ "zoom-service-account",
+ "---All Integrations---",
"a2a",
"agentmail",
"agentphone",
"agiloft",
"ahrefs",
"airtable",
- "airtable-service-account",
"airweave",
"algolia",
"amplitude",
@@ -16,12 +35,9 @@
"appconfig",
"arxiv",
"asana",
- "asana-service-account",
"ashby",
"athena",
- "atlassian-service-account",
"attio",
- "attio-service-account",
"azure_devops",
"box",
"brandfetch",
@@ -30,7 +46,6 @@
"browser_use",
"buffer",
"calcom",
- "calcom-service-account",
"calendly",
"circleback",
"clay",
@@ -81,7 +96,6 @@
"gitlab",
"gmail",
"gong",
- "google-service-account",
"google_ads",
"google_appsheet",
"google_bigquery",
@@ -108,7 +122,6 @@
"greptile",
"hex",
"hubspot",
- "hubspot-service-account",
"hubspot-setup",
"huggingface",
"hunter",
@@ -133,7 +146,6 @@
"leadmagic",
"lemlist",
"linear",
- "linear-service-account",
"linkedin",
"linkup",
"linq",
@@ -152,14 +164,12 @@
"millionverifier",
"mistral_parse",
"monday",
- "monday-service-account",
"mongodb",
"mysql",
"neo4j",
"neverbounce",
"new_relic",
"notion",
- "notion-service-account",
"obsidian",
"okta",
"onedrive",
@@ -206,7 +216,6 @@
"sftp",
"sharepoint",
"shopify",
- "shopify-service-account",
"similarweb",
"sixtyfour",
"slack",
@@ -228,7 +237,6 @@
"thrive",
"tinybird",
"trello",
- "trello-service-account",
"trigger_dev",
"twilio",
"twilio_sms",
@@ -239,9 +247,7 @@
"vanta",
"vercel",
"wealthbox",
- "wealthbox-service-account",
"webflow",
- "webflow-service-account",
"whatsapp",
"wikipedia",
"wiza",
diff --git a/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx b/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx
new file mode 100644
index 00000000000..821fe5a156c
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx
@@ -0,0 +1,74 @@
+---
+title: Pipedrive API Tokens
+description: Connect Pipedrive to Sim with a personal API token instead of an OAuth login
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Pipedrive personal API tokens let your workflows authenticate to Pipedrive without a person's OAuth login. The token is issued per user per company, doesn't expire on its own, and stays valid until it's regenerated or the user is deactivated — no OAuth consent to renew.
+
+The token carries the full data access of the Pipedrive user it belongs to, in that one company. For production workflows, create it from a dedicated Pipedrive user so a teammate leaving or regenerating their token doesn't break your automations.
+
+## Prerequisites
+
+Any Pipedrive user with API access enabled can copy their token. If the API page is hidden, a Pipedrive admin may have disabled API access for non-admin users — an admin can enable it from the company's user settings.
+
+
+Each user has exactly **one** active API token per company. Regenerating it immediately invalidates the old value everywhere it's used, with no grace period. If the token is shared with other integrations, coordinate before regenerating.
+
+
+## Finding the API Token
+
+
+
+ In Pipedrive, click your profile picture in the top right, then **Personal preferences** → **API** — or go directly to `https://app.pipedrive.com/settings/api`
+
+
+ Copy **Your personal API token** (generate one if the field is empty)
+
+
+
+
+The API token grants everything its user can see and do in Pipedrive. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
+
+
+## Adding the API Token to Sim
+
+
+
+ Open **Integrations** from your workspace sidebar
+
+
+ Search for "Pipedrive" and open it, then click **Add to Sim** and choose **Add API token**
+
+
+ Paste the **API token**, and optionally set a display name and description
+
+
+ Click **Add API token**. Sim verifies the token against Pipedrive (`GET /v1/users/me`) and names the credential after the Pipedrive user and company — if verification fails, you'll see a specific error explaining what went wrong.
+
+
+
+## Using the API Token in Workflows
+
+Add a Pipedrive block to your workflow. In the credential dropdown, your Pipedrive API token appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+Sim sends the token in Pipedrive's `x-api-token` header (the documented scheme for personal API tokens), so every Pipedrive tool works unchanged.
+
+
+Pipedrive gives API-token traffic lower burst rate limits than OAuth (roughly a quarter of the OAuth allowance, by plan), and every company shares one daily API budget across all users and both auth methods. Heavy workflow schedules can eat into the budget your other Pipedrive integrations use.
+
+
+## Rotating the Token
+
+Pipedrive tokens don't expire on a schedule. To rotate one, regenerate it on the same **Personal preferences** → **API** page, then paste the new value into the credential in Sim right away — the old token stops working the moment a new one is generated.
+
+
diff --git a/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx
new file mode 100644
index 00000000000..5c18f72f256
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx
@@ -0,0 +1,174 @@
+---
+title: Salesforce Integration Users
+description: Set up a Salesforce External Client App with the Client Credentials Flow and a dedicated integration user to use Salesforce in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Salesforce's OAuth 2.0 Client Credentials Flow lets your workflows authenticate to Salesforce as a dedicated **integration user** instead of through a person's OAuth login. An admin creates an External Client App once, enables the flow, and picks the "Run As" user whose permissions every API call executes with — no user consent to expire, and data access that's governed entirely by that user's profile and permission sets.
+
+This is the recommended way to use Salesforce in production workflows: nothing depends on a person staying logged in, and what the credential can touch is exactly what the integration user can touch.
+
+## Prerequisites
+
+You need a Salesforce **admin** to create the External Client App and the integration user. Every API call Sim makes runs with the integration user's permissions, so plan that user's profile and permission sets deliberately.
+
+## Setting Up the External Client App
+
+### 1. Create a Dedicated Integration User
+
+
+
+ In Salesforce, go to **Setup** → **Users** → **New User**. Set **License** to **Salesforce Integration** (the free API-only license) and **Profile** to **Minimum Access - API Only Integrations**
+
+ {/* TODO(screenshot): New User form with the Salesforce Integration license and API-only profile selected */}
+
+
+ Create a permission set backed by the **Salesforce API Integration** permission set license, grant it the object and field permissions your workflows need (least privilege), and assign it to the user
+
+
+
+
+If your org doesn't have Salesforce Integration licenses, any standard user with the **API Enabled** permission works too — but a dedicated API-only user keeps the credential's access explicit and auditable.
+
+
+### 2. Create the External Client App
+
+External Client Apps are Salesforce's current-generation connected apps and the default way to create new OAuth apps.
+
+
+
+ Go to **Setup** → **External Client App Manager** → **New External Client App**, give the app a name and contact email, and keep it local to your org (not packaged for distribution)
+
+ {/* TODO(screenshot): New External Client App form with the basic details filled in */}
+
+
+ In the OAuth settings section, enable OAuth (exact labels vary by Salesforce release)
+
+
+ Enter any placeholder **Callback URL** (e.g. `https://login.salesforce.com/services/oauth2/callback`) — it's required by the form but unused by this flow
+
+
+ Add the OAuth scopes **Manage user data via APIs (api)** and **Access unique user identifiers (openid)** — `api` is required for the flow, and `openid` lets Sim look up the integration user's name via the userinfo endpoint (the instance URL comes back in the token response itself)
+
+
+ Enable the **Client Credentials Flow** in the OAuth settings, acknowledge the warning, and create the app
+
+ {/* TODO(screenshot): OAuth settings with Enable Client Credentials Flow checked */}
+
+
+
+### 3. Configure the Run As User
+
+
+
+ Open your app in **External Client App Manager** and edit its **Policies**
+
+
+ Under **OAuth Policies** → **Client Credentials Flow**, check **Enable Client Credentials Flow** and set **Run As** to the integration user from step 1, then save
+
+ {/* TODO(screenshot): OAuth Policies with the Run As integration user set under Client Credentials Flow */}
+
+
+
+Every API call Sim makes executes with this user's permissions.
+
+### 4. Copy the Consumer Key and Secret
+
+Open the app's **Settings** → **OAuth Settings** and click **Consumer Key and Secret** (Salesforce prompts for identity verification). Copy the **Consumer Key** and **Consumer Secret**.
+
+{/* TODO(screenshot): OAuth Settings page showing the Consumer Key and Consumer Secret */}
+
+
+The Consumer Secret plus the Run As configuration is full API access as the integration user. Treat both values like passwords — do not commit them to source control or share them publicly. Sim encrypts them at rest.
+
+
+### Using an Existing Connected App (Legacy)
+
+
+Creating **new** Connected Apps is blocked by default: since Summer '25 new orgs ship with Connected App creation disabled, and Spring '26 turned it off across all orgs — re-enabling it requires a request to Salesforce Support. Use an External Client App for new setups.
+
+
+If you already have a classic Connected App, it keeps working and the credential fields are identical — the token endpoint and Sim configuration don't change. Configure it the classic way:
+
+
+
+ In **Setup** → **App Manager**, confirm the app has **Enable OAuth Settings**, the **Manage user data via APIs (api)** and **Access unique user identifiers (openid)** scopes, and **Enable Client Credentials Flow** checked
+
+
+ From **App Manager**, open the app's **Manage** page, click **Edit Policies**, and set **Run As** under **Client Credentials Flow** to your integration user. Permitted Users policies don't apply to the Client Credentials Flow's execution user, so no pre-authorization is needed
+
+
+ Open the app with **View** and click **Manage Consumer Details** to copy the **Consumer Key** and **Consumer Secret**
+
+
+
+### 5. Find Your My Domain Host
+
+Go to **Setup** and search for **My Domain**. The host is required — Salesforce rejects the Client Credentials Flow at `login.salesforce.com` and `test.salesforce.com`. Depending on your org type it looks like:
+
+- **Production:** `yourorg.my.salesforce.com`
+- **Sandbox:** `yourorg--sandboxname.sandbox.my.salesforce.com`
+- **Developer Edition:** `yourorg-dev-ed.develop.my.salesforce.com`
+
+Sim also accepts other partitioned My Domain hosts (`scratch`, `demo`, `patch`, `trailblaze`, `free`). Government and military domains (`*.my.salesforce.mil`) aren't currently supported.
+
+## Permissions Instead of Scopes
+
+There's no scope picking beyond the **api** and **openid** scopes on the app — what the credential can actually do is the integration user's profile plus permission sets. In particular:
+
+- Object and field access for every object your workflows read or write, plus **API Enabled**
+- **Customize Application** for tools that manage custom fields and custom objects (Tooling API) — a Minimum Access API-only user passes validation but fails these specific tools without it
+- **Run Reports** and folder access for report and dashboard tools
+
+A permissions gap surfaces at run time as a Salesforce API error; fix it on the integration user's permission sets — no changes are needed in Sim.
+
+## Adding the Service Account to Sim
+
+
+
+ Open **Integrations** from your workspace sidebar
+
+
+ Search for "Salesforce" and open it, then click **Add to Sim** and choose **Add integration user app**
+
+ {/* TODO(screenshot): Salesforce integration page with the Add integration user app connect option */}
+
+
+ In the **Add Salesforce integration user app** dialog, paste the **Consumer key**, **Consumer secret**, and **My Domain host** (e.g. `yourorg.my.salesforce.com`), and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Salesforce integration user app dialog with all three fields filled in */}
+
+
+ Click **Add integration user app**. Sim verifies the credentials by minting a real access token against your My Domain host. A host that doesn't resolve gets its own error message; a bad consumer key or secret and a flow that isn't fully configured both surface as a general authentication error — re-check all three values and the app's Client Credentials Flow policies.
+
+
+
+## Using the Service Account in Workflows
+
+Add a Salesforce block to your workflow. In the credential dropdown, your Salesforce service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Salesforce block in a workflow with the Salesforce service account selected as the credential */}
+
+The block calls your org's REST API with a freshly minted access token — the same requests as the OAuth flow, so every Salesforce tool works, subject to the integration user's permissions.
+
+## Token Behavior
+
+Access tokens from this flow have no fixed lifetime in the response — an opaque token stays valid until the Run As user's session times out (2 hours by default; configurable from 15 minutes to 24 hours in Session Settings). There is no refresh token; Sim mints a new token whenever one is needed, so session timeouts are invisible to your workflows.
+
+
+Deactivating or freezing the Run As user stops all token minting with an `invalid_grant` error, halting every workflow that uses the credential. Password policies that expire the user's API access have the same effect. Treat the integration user as production infrastructure.
+
+
+
diff --git a/apps/docs/content/docs/en/integrations/zoom-service-account.mdx b/apps/docs/content/docs/en/integrations/zoom-service-account.mdx
new file mode 100644
index 00000000000..163a1ec9cb3
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/zoom-service-account.mdx
@@ -0,0 +1,150 @@
+---
+title: Zoom Server-to-Server OAuth Apps
+description: Set up a Zoom Server-to-Server OAuth app so your workflows can call Zoom without a personal OAuth login
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Zoom Server-to-Server OAuth apps let your workflows authenticate to Zoom at the account level instead of through a person's OAuth login. A Zoom admin creates the app once, grants it exactly the scopes it needs, and Sim mints short-lived access tokens from the app's credentials on demand — no user consent to expire, and permissions that are auditable from the Zoom Marketplace.
+
+This is the recommended way to use Zoom in production workflows: the credentials don't depend on any user staying logged in, the granted scopes are explicit, and tokens are minted fresh whenever a workflow runs.
+
+## Prerequisites
+
+You need a Zoom **admin** whose role has permission to view and edit Server-to-Server OAuth apps, plus permission for the scopes the app will use. The **Develop** option in the Zoom Marketplace only appears for accounts with this permission.
+
+
+Scope selection is capped by the creating admin's own role permissions, and Zoom automatically removes admin-level scopes from the app if that admin's role is later downgraded — workflows then start failing with scope errors even though nothing changed in Sim. Create the app from an admin account you expect to keep.
+
+
+## Setting Up the Server-to-Server OAuth App
+
+### 1. Create the App
+
+
+
+ Sign in at [marketplace.zoom.us](https://marketplace.zoom.us) as a Zoom admin, click **Develop** → **Build an app**, select **Server-to-Server OAuth app**, and click **Create**. Give it a name (e.g. `Sim`)
+
+ {/* TODO(screenshot): Zoom Marketplace Build an app dialog with Server-to-Server OAuth app selected */}
+
+
+ On the **App Credentials** page, copy the **Account ID**, **Client ID**, and **Client secret** — these are the three values you'll paste into Sim
+
+ {/* TODO(screenshot): App Credentials page showing Account ID, Client ID, and Client secret */}
+
+
+ Use the Account ID from this **App Credentials** page — it's an opaque string, not a number. The numeric account number shown in your Zoom web-portal profile is a different value, and pasting it is a documented common mistake that produces "bad request" errors when minting tokens.
+
+
+
+ In the **Information** section, fill in the required short description, company name, and developer name and email — the app can't be activated without them
+
+
+ In **Scopes**, click **Add Scopes** and add the admin-level granular scopes your workflows need (see the list below)
+
+ {/* TODO(screenshot): Scopes picker with admin granular scopes selected */}
+
+
+ In **Activation**, resolve any remaining blockers and activate the app
+
+
+ Tokens cannot be minted until the app is activated — and deactivating it later immediately kills every token it has issued, stopping all workflows that use the credential.
+
+
+
+
+### 2. Choose Scopes
+
+Server-to-Server OAuth apps use Zoom's granular scopes with account-level (`:admin`) variants. Grant only what your workflows use. The set Sim's Zoom tools can draw on is:
+
+**Users:**
+```
+user:read:user:admin
+```
+
+**Meetings:**
+```
+meeting:read:meeting:admin
+meeting:read:list_meetings:admin
+meeting:write:meeting:admin
+meeting:update:meeting:admin
+meeting:delete:meeting:admin
+meeting:read:invitation:admin
+meeting:read:list_past_participants:admin
+```
+
+**Cloud recordings:**
+```
+cloud_recording:read:list_user_recordings:admin
+cloud_recording:read:list_recording_files:admin
+cloud_recording:delete:recording_file:admin
+```
+
+
+Match these against what the Marketplace **Scopes** picker actually offers — Zoom's granular-scope catalog shows the `:admin` variants for account-level apps, but names occasionally differ from the user-level scopes Sim's OAuth flow requests. If a picker entry differs slightly from this list, prefer the picker's name for the matching action.
+
+
+A missing scope surfaces at run time as a `4xx` error from the Zoom API naming a scope problem. Add the scope in the app's **Scopes** section and re-run the workflow — no changes are needed in Sim.
+
+### 3. Copy the Credentials
+
+The Client secret is bearer material for your entire Zoom account, limited only by the app's scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts it at rest.
+
+
+Regenerating the Client secret in the Zoom Marketplace invalidates the stored pair. If you rotate it, update the credential in Sim right away.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open **Integrations** from your workspace sidebar
+
+
+ Search for "Zoom" and open it, then click **Add to Sim** and choose **Add server-to-server app**
+
+ {/* TODO(screenshot): Zoom integration page with the Add server-to-server app connect option */}
+
+
+ In the **Add Zoom server-to-server app** dialog, paste the **Client ID**, **Client secret**, and **Account ID**, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Zoom server-to-server app dialog with all three fields filled in */}
+
+
+ Click **Add server-to-server app**. Sim verifies the credentials by minting a real access token from Zoom — if it fails, the error tells you whether Zoom rejected the credentials or couldn't be reached. A rejection usually means bad credentials, an app that isn't a Server-to-Server OAuth app, or an app that hasn't been activated.
+
+
+
+## Using the Service Account in Workflows
+
+Add a Zoom block to your workflow. In the credential dropdown, your Zoom service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Zoom block in a workflow with the Zoom service account selected as the credential */}
+
+The block calls `api.zoom.us` with a freshly minted access token — the same requests as the OAuth flow, so every Zoom tool works, subject to the scopes you granted.
+
+
+**The `me` keyword does not work with service accounts.** With a personal OAuth connection, user-scoped operations (create meeting, list meetings, list recordings) accept `me` for the connected user. A Server-to-Server app has no user context — Zoom rejects `me` with an error like "This API does not support client credentials for authorization." Always pass an explicit user ID or email address in the block's user field when using a service account.
+
+
+
+Sim's Zoom tools call the standard `api.zoom.us` host. Standard commercial Zoom accounts work as-is; Zoom for Government and other regionally partitioned accounts may not be reachable at that host and aren't currently supported with service accounts.
+
+
+## Token Behavior
+
+Access tokens minted from the app live for one hour and there is no refresh token — Sim simply mints a new token when one is needed. Zoom explicitly allows multiple concurrently valid tokens, so minting never invalidates workflows already in flight. Two events do kill tokens:
+
+- **Deactivating the app** — all minted tokens die immediately and no new ones can be issued until it's reactivated
+- **Regenerating the Client secret** — the stored credentials stop working; paste the new secret into the credential in Sim
+
+
diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts
index ad468ac3b9f..e1ef6105675 100644
--- a/apps/sim/app/api/auth/oauth/token/route.test.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.test.ts
@@ -12,16 +12,21 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockAuthorizeCredentialUse } = vi.hoisted(() => ({
+const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
mockAuthorizeCredentialUse: vi.fn(),
+ mockResolveServiceAccountToken: vi.fn(),
}))
-vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
+vi.mock('@/app/api/auth/oauth/utils', () => ({
+ ...authOAuthUtilsMock,
+ resolveServiceAccountToken: mockResolveServiceAccountToken,
+}))
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
@@ -195,6 +200,107 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('error', 'Failed to refresh access token')
})
+ describe('service account path', () => {
+ it('should thread authStyle from the resolver into the response', async () => {
+ authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
+ accountId: '',
+ credentialId: 'sa-credential-id',
+ credentialType: 'service_account',
+ providerId: 'pipedrive-service-account',
+ workspaceId: 'workspace-id',
+ usedCredentialTable: true,
+ })
+ mockAuthorizeCredentialUse.mockResolvedValueOnce({
+ ok: true,
+ authType: 'session',
+ requesterUserId: 'test-user-id',
+ workspaceId: 'workspace-id',
+ })
+ mockResolveServiceAccountToken.mockResolvedValueOnce({
+ accessToken: 'pasted-api-token',
+ authStyle: 'x-api-token',
+ })
+
+ const req = createMockRequest('POST', { credentialId: 'sa-credential-id' })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data).toHaveProperty('accessToken', 'pasted-api-token')
+ expect(data).toHaveProperty('authStyle', 'x-api-token')
+ })
+
+ it('should omit authStyle for Bearer token-paste providers', async () => {
+ authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
+ accountId: '',
+ credentialId: 'sa-credential-id',
+ credentialType: 'service_account',
+ providerId: 'hubspot-service-account',
+ workspaceId: 'workspace-id',
+ usedCredentialTable: true,
+ })
+ mockAuthorizeCredentialUse.mockResolvedValueOnce({
+ ok: true,
+ authType: 'session',
+ requesterUserId: 'test-user-id',
+ workspaceId: 'workspace-id',
+ })
+ mockResolveServiceAccountToken.mockResolvedValueOnce({
+ accessToken: 'pat-token',
+ })
+
+ const req = createMockRequest('POST', { credentialId: 'sa-credential-id' })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data).toHaveProperty('accessToken', 'pat-token')
+ expect(data).not.toHaveProperty('authStyle')
+ })
+
+ it.each([
+ ['invalid_credentials', 401],
+ ['site_not_found', 400],
+ ['provider_unavailable', 502],
+ ] as const)(
+ 'surfaces the %s error code with status %i when the mint fails',
+ async (code, status) => {
+ authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
+ accountId: '',
+ credentialId: 'sa-credential-id',
+ credentialType: 'service_account',
+ providerId: 'salesforce-service-account',
+ workspaceId: 'workspace-id',
+ usedCredentialTable: true,
+ })
+ mockAuthorizeCredentialUse.mockResolvedValueOnce({
+ ok: true,
+ authType: 'session',
+ requesterUserId: 'test-user-id',
+ workspaceId: 'workspace-id',
+ })
+ mockResolveServiceAccountToken.mockRejectedValueOnce(
+ new TokenServiceAccountValidationError(code, status, { step: 'mint' })
+ )
+
+ const req = createMockRequest('POST', { credentialId: 'sa-credential-id' })
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(status)
+ // provider_unavailable is an infra failure, not a credential error, so
+ // it intentionally does not carry a client-actionable `code`.
+ if (code === 'provider_unavailable') {
+ expect(data).not.toHaveProperty('code')
+ } else {
+ expect(data).toHaveProperty('code', code)
+ }
+ }
+ )
+ })
+
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index ccc557389cb..b767b59325e 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -11,6 +11,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getCredential,
@@ -180,11 +181,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
accessToken: result.accessToken,
cloudId: result.cloudId,
domain: result.domain,
+ instanceUrl: result.instanceUrl,
+ authStyle: result.authStyle,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
+ if (error instanceof TokenServiceAccountValidationError) {
+ // Classified provider outages are infra failures, not bad credentials.
+ if (error.code === 'provider_unavailable') {
+ return NextResponse.json(
+ { error: 'Credential provider is temporarily unavailable' },
+ { status: 502 }
+ )
+ }
+ // A stored host that no longer resolves is a configuration failure —
+ // surface the code so runtime consumers can say "check the host"
+ // instead of a generic auth error.
+ if (error.code === 'site_not_found') {
+ return NextResponse.json(
+ {
+ code: error.code,
+ error: 'Credential host not found — reconnect the credential with a valid host',
+ },
+ { status: 400 }
+ )
+ }
+ // A revoked/rotated-away or misconfigured stored secret — surface the
+ // code so runtime consumers can prompt to reconnect the credential
+ // rather than showing a generic auth failure.
+ if (error.code === 'invalid_credentials') {
+ return NextResponse.json(
+ {
+ code: error.code,
+ error: 'Credential rejected by the provider — reconnect the credential',
+ },
+ { status: 401 }
+ )
+ }
+ }
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
index 77457cf3328..0e6550d44ea 100644
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -20,8 +20,15 @@ vi.mock('@/lib/core/security/encryption', () => ({
encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
}))
+const { mockMinter } = vi.hoisted(() => ({ mockMinter: vi.fn() }))
+vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
+ getClientCredentialAccountMinter: vi.fn(() => mockMinter),
+ parseClientCredentialAccountSecretBlob: vi.fn((decrypted: string) => JSON.parse(decrypted)),
+}))
+
import { db } from '@sim/db'
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
+import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
import { refreshOAuthToken } from '@/lib/oauth'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -331,4 +338,129 @@ describe('OAuth Utils', () => {
).rejects.toThrow(/Scopes are required/)
})
})
+
+ describe('resolveServiceAccountToken — client-credential mint cache', () => {
+ const ENCRYPTED_KEY_A = `${'a'.repeat(32)}rest-of-ciphertext`
+ const ENCRYPTED_KEY_B = `${'b'.repeat(32)}rest-of-ciphertext`
+ const BLOB_FIELDS = { clientId: 'cid', clientSecret: 'cs', orgId: 'org' }
+
+ let now: number
+ let dateNowSpy: ReturnType
+
+ beforeEach(() => {
+ now = 1_750_000_000_000
+ dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
+ mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(BLOB_FIELDS) })
+ })
+
+ afterEach(() => {
+ dateNowSpy.mockRestore()
+ })
+
+ function mockCredentialRow(encryptedServiceAccountKey: string) {
+ mockSelectChain([{ encryptedServiceAccountKey }])
+ }
+
+ it('mints once with skipIdentity, then serves cache hits preserving instanceUrl', async () => {
+ const credId = 'ccsa-cache-hit'
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({
+ accessToken: 'tok-1',
+ expiresInSeconds: 3600,
+ instanceUrl: 'https://org.my.salesforce.com',
+ })
+
+ const first = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(first).toEqual({
+ accessToken: 'tok-1',
+ instanceUrl: 'https://org.my.salesforce.com',
+ })
+ expect(mockMinter).toHaveBeenCalledWith(BLOB_FIELDS, { skipIdentity: true })
+
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(second).toEqual({
+ accessToken: 'tok-1',
+ instanceUrl: 'https://org.my.salesforce.com',
+ })
+ expect(mockMinter).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-mints when remaining validity is below the 5-minute serve floor', async () => {
+ const credId = 'ccsa-ttl-floor'
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'tok-1', expiresInSeconds: 240 })
+
+ await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'tok-2', expiresInSeconds: 3600 })
+ const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(second.accessToken).toBe('tok-2')
+ expect(mockMinter).toHaveBeenCalledTimes(2)
+ })
+
+ it('re-mints when the stored secret fingerprint changes (credential rotation)', async () => {
+ const credId = 'ccsa-rotation'
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'old-app-token', expiresInSeconds: 3600 })
+
+ await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ mockCredentialRow(ENCRYPTED_KEY_B)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'new-app-token', expiresInSeconds: 3600 })
+ const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(second.accessToken).toBe('new-app-token')
+ expect(mockMinter).toHaveBeenCalledTimes(2)
+ })
+
+ it('never caches a failed mint as a token but memoizes the failure for ~30s', async () => {
+ const credId = 'ccsa-negative-memo'
+ const mintError = new Error('invalid_credentials')
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockRejectedValueOnce(mintError)
+
+ await expect(
+ resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+ ).rejects.toBe(mintError)
+
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ await expect(
+ resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+ ).rejects.toBe(mintError)
+ expect(mockMinter).toHaveBeenCalledTimes(1)
+
+ now += 31_000
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'tok-after', expiresInSeconds: 3600 })
+ const result = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(result.accessToken).toBe('tok-after')
+ expect(mockMinter).toHaveBeenCalledTimes(2)
+ })
+
+ it('evicts the cached token when the credential row is deleted', async () => {
+ const credId = 'ccsa-deleted'
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'tok-1', expiresInSeconds: 3600 })
+
+ await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ mockSelectChain([])
+ await expect(
+ resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+ ).rejects.toThrow(/secret not found/)
+
+ mockCredentialRow(ENCRYPTED_KEY_A)
+ mockMinter.mockResolvedValueOnce({ accessToken: 'tok-2', expiresInSeconds: 3600 })
+ const result = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID)
+
+ expect(result.accessToken).toBe('tok-2')
+ expect(mockMinter).toHaveBeenCalledTimes(2)
+ })
+ })
})
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 5bd9ce0090c..4fd56f0e0a7 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -7,7 +7,15 @@ import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { decryptSecret } from '@/lib/core/security/encryption'
-import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
+import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
+import {
+ getClientCredentialAccountMinter,
+ parseClientCredentialAccountSecretBlob,
+} from '@/lib/credentials/client-credential-accounts/server'
+import {
+ getTokenServiceAccountDescriptor,
+ isTokenServiceAccountProviderId,
+} from '@/lib/credentials/token-service-accounts/descriptors'
import {
parseTokenServiceAccountSecretBlob,
type TokenServiceAccountSecretBlob,
@@ -337,6 +345,14 @@ export interface ServiceAccountTokenResult {
cloudId?: string
/** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
domain?: string
+ /** Salesforce only — the org's instance URL the token must be used against. */
+ instanceUrl?: string
+ /**
+ * Set when the token must be sent in an `x-api-token` header instead of
+ * `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means
+ * Bearer; OAuth credentials never carry it.
+ */
+ authStyle?: 'x-api-token'
}
/**
@@ -362,6 +378,156 @@ async function getTokenServiceAccountSecret(
return parseTokenServiceAccountSecretBlob(decrypted, providerId)
}
+interface CachedClientCredentialToken {
+ accessToken: string
+ expiresAtMs: number
+ /**
+ * Fingerprint of the encrypted secret the token was minted from (see
+ * {@link secretFingerprintOf}). A reconnect that re-points the credential at
+ * different client credentials changes the ciphertext, so a mismatch means
+ * the cached token belongs to the old app and must be re-minted.
+ */
+ secretFingerprint: string
+ /** Salesforce only — the instance URL returned alongside the minted token. */
+ instanceUrl?: string
+}
+
+interface FailedClientCredentialMint {
+ /** The error the failed mint threw, re-thrown to callers while memoized. */
+ error: unknown
+ secretFingerprint: string
+ expiresAtMs: number
+}
+
+/**
+ * Per-instance cache of minted client-credential access tokens (Zoom S2S,
+ * Box CCG, Salesforce client-credentials), keyed by credential id. Entries are
+ * served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of
+ * validity remains, so a hot credential mints roughly once per token TTL
+ * (~1h for Zoom/Box; Salesforce reports a conservative 10-minute TTL because
+ * its responses never carry an expiry) per instance.
+ *
+ * Every resolution re-reads the credential row (a cheap indexed PK select —
+ * the mint is the expensive part) and validates the cached entry's secret
+ * fingerprint against the live ciphertext, so rotating or re-pointing a
+ * credential takes effect on the next resolution on every instance, and a
+ * credential that is re-resolved after deletion evicts its own entries. No
+ * cross-instance lock is needed: mints are stateless and these providers allow
+ * multiple concurrently valid tokens, so each instance minting its own token
+ * is correct.
+ *
+ * Failed mints are never cached as tokens; instead they are memoized for
+ * {@link CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS} so a hot workflow on a
+ * revoked/invalid secret doesn't hammer the provider's token endpoint once
+ * per block execution.
+ *
+ * Both maps are pruned of expired entries on each resolution
+ * ({@link pruneExpiredClientCredentialCaches}), so their size is bounded by the
+ * credentials resolved within the last token lifetime — entries for credentials
+ * that are never resolved again do not accumulate indefinitely.
+ */
+const clientCredentialTokenCache = new Map()
+const clientCredentialMintFailureCache = new Map()
+const CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS = 5 * 60 * 1000
+const CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS = 30 * 1000
+
+/** Drops fully-expired token and failure entries so the maps stay bounded. */
+function pruneExpiredClientCredentialCaches(nowMs: number): void {
+ for (const [id, entry] of clientCredentialTokenCache) {
+ if (entry.expiresAtMs <= nowMs) clientCredentialTokenCache.delete(id)
+ }
+ for (const [id, entry] of clientCredentialMintFailureCache) {
+ if (entry.expiresAtMs <= nowMs) clientCredentialMintFailureCache.delete(id)
+ }
+}
+
+/**
+ * Rotation fingerprint for a stored encrypted secret: the ciphertext prefix
+ * (IV + first blocks) is unique per encryption, so any re-encrypt — secret
+ * rotation or re-pointing at a different app — changes it.
+ */
+function secretFingerprintOf(encryptedServiceAccountKey: string): string {
+ return encryptedServiceAccountKey.slice(0, 32)
+}
+
+/**
+ * Resolves a client-credential service-account credential to a short-lived
+ * access token: decrypts the stored client id/secret + org id 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.
+ */
+async function resolveClientCredentialAccountToken(
+ credentialId: string,
+ providerId: string
+): Promise {
+ return coalesceLocally(`ccsa:${credentialId}`, async () => {
+ pruneExpiredClientCredentialCaches(Date.now())
+ const [credentialRow] = await db
+ .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+ if (!credentialRow?.encryptedServiceAccountKey) {
+ clientCredentialTokenCache.delete(credentialId)
+ clientCredentialMintFailureCache.delete(credentialId)
+ throw new Error('Client-credential service account secret not found')
+ }
+ const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey)
+
+ const cached = clientCredentialTokenCache.get(credentialId)
+ if (
+ cached &&
+ cached.secretFingerprint === secretFingerprint &&
+ cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS
+ ) {
+ return { accessToken: cached.accessToken, instanceUrl: cached.instanceUrl }
+ }
+
+ const failed = clientCredentialMintFailureCache.get(credentialId)
+ if (
+ failed &&
+ failed.secretFingerprint === secretFingerprint &&
+ Date.now() < failed.expiresAtMs
+ ) {
+ throw failed.error
+ }
+ clientCredentialMintFailureCache.delete(credentialId)
+
+ try {
+ const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
+ 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,
+ clientSecret: blob.clientSecret,
+ orgId: blob.orgId,
+ },
+ { skipIdentity: true }
+ )
+ clientCredentialTokenCache.set(credentialId, {
+ accessToken: mint.accessToken,
+ expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
+ secretFingerprint,
+ instanceUrl: mint.instanceUrl,
+ })
+ return { accessToken: mint.accessToken, instanceUrl: mint.instanceUrl }
+ } catch (error) {
+ clientCredentialMintFailureCache.set(credentialId, {
+ error,
+ secretFingerprint,
+ expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS,
+ })
+ throw error
+ }
+ })
+}
+
interface ServiceAccountTokenOptions {
scopes?: string[]
impersonateEmail?: string
@@ -412,7 +578,15 @@ export async function resolveServiceAccountToken(
): Promise {
if (providerId && isTokenServiceAccountProviderId(providerId)) {
const secret = await getTokenServiceAccountSecret(credentialId, providerId)
- return { accessToken: secret.apiToken, domain: secret.domain }
+ const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle
+ return {
+ accessToken: secret.apiToken,
+ domain: secret.domain,
+ ...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}),
+ }
+ }
+ if (providerId && isClientCredentialAccountProviderId(providerId)) {
+ return resolveClientCredentialAccountToken(credentialId, providerId)
}
const resolver =
providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId)
@@ -660,21 +834,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
}
/**
- * Refreshes an OAuth token if needed based on credential information.
- * Also handles service account credentials by generating a JWT-based token.
- * @param credentialId The ID of the credential to check and potentially refresh
- * @param userId The user ID who owns the credential (for security verification)
- * @param requestId Request ID for log correlation
- * @param scopes Optional scopes for service account token generation
- * @returns The valid access token or null if refresh fails
+ * Resolves a credential to its access token plus provider metadata
+ * (`cloudId`/`domain`/`instanceUrl`/`authStyle`). Behaves exactly like
+ * {@link refreshAccessTokenIfNeeded} but returns the full
+ * {@link ServiceAccountTokenResult} so callers that build provider requests
+ * directly (e.g. selector routes) can honor non-Bearer auth styles such as
+ * Pipedrive's `x-api-token`. OAuth credentials resolve with `accessToken`
+ * only.
*/
-export async function refreshAccessTokenIfNeeded(
+export async function resolveCredentialAccessToken(
credentialId: string,
userId: string,
requestId: string,
scopes?: string[],
impersonateEmail?: string
-): Promise {
+): Promise {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
@@ -682,13 +856,12 @@ export async function refreshAccessTokenIfNeeded(
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
logger.info(`[${requestId}] Using service account token for credential`)
- const { accessToken } = await resolveServiceAccountToken(
+ return resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes,
impersonateEmail
)
- return accessToken
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
@@ -745,13 +918,13 @@ export async function refreshAccessTokenIfNeeded(
requestId,
userId: credential.userId,
})
- if (fresh) return fresh
+ if (fresh) return { accessToken: fresh }
// If refresh was only triggered proactively (Microsoft refresh-token aging /
// Instagram long-lived nearing expiry), the still-valid access token is fine.
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
- return accessToken
+ return { accessToken }
}
return null
}
@@ -762,7 +935,34 @@ export async function refreshAccessTokenIfNeeded(
}
logger.info(`[${requestId}] Access token is valid for credential`)
- return accessToken
+ return { accessToken }
+}
+
+/**
+ * Refreshes an OAuth token if needed based on credential information.
+ * Also handles service account credentials by generating a JWT-based token.
+ * Thin string wrapper over {@link resolveCredentialAccessToken}.
+ * @param credentialId The ID of the credential to check and potentially refresh
+ * @param userId The user ID who owns the credential (for security verification)
+ * @param requestId Request ID for log correlation
+ * @param scopes Optional scopes for service account token generation
+ * @returns The valid access token or null if refresh fails
+ */
+export async function refreshAccessTokenIfNeeded(
+ credentialId: string,
+ userId: string,
+ requestId: string,
+ scopes?: string[],
+ impersonateEmail?: string
+): Promise {
+ const result = await resolveCredentialAccessToken(
+ credentialId,
+ userId,
+ requestId,
+ scopes,
+ impersonateEmail
+ )
+ return result?.accessToken ?? null
}
/**
diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts
index 19f8ba89f1d..c0d72341ed5 100644
--- a/apps/sim/app/api/credentials/[id]/route.ts
+++ b/apps/sim/app/api/credentials/[id]/route.ts
@@ -86,6 +86,9 @@ export const PUT = withRouteHandler(
botToken: body.botToken,
apiToken: body.apiToken,
domain: body.domain,
+ clientId: body.clientId,
+ clientSecret: body.clientSecret,
+ orgId: body.orgId,
request,
})
if (!result.success) {
@@ -96,9 +99,13 @@ export const PUT = withRouteHandler(
? 403
: result.errorCode === 'conflict'
? 409
- : result.errorCode === 'validation'
- ? 400
- : 500
+ : // A provider outage during reconnect is infra, not a bad
+ // request — mirror the create route and runtime token route.
+ result.providerErrorCode === 'provider_unavailable'
+ ? 502
+ : result.errorCode === 'validation'
+ ? 400
+ : 500
return NextResponse.json(
{
error: result.error,
diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts
new file mode 100644
index 00000000000..c673b39b1a1
--- /dev/null
+++ b/apps/sim/app/api/credentials/route.test.ts
@@ -0,0 +1,160 @@
+/**
+ * Tests for the workspace credentials API route (create path).
+ *
+ * @vitest-environment node
+ */
+import { auditMock, authMockFns, createMockRequest, posthogServerMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+
+const {
+ mockCheckWorkspaceAccess,
+ mockGetWorkspaceMembership,
+ mockVerifyAndBuildServiceAccountSecret,
+} = vi.hoisted(() => ({
+ mockCheckWorkspaceAccess: vi.fn(),
+ mockGetWorkspaceMembership: vi.fn(),
+ mockVerifyAndBuildServiceAccountSecret: vi.fn(),
+}))
+
+vi.mock('@sim/audit', () => auditMock)
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ checkWorkspaceAccess: mockCheckWorkspaceAccess,
+}))
+
+vi.mock('@/lib/credentials/environment', () => ({
+ getWorkspaceMembership: mockGetWorkspaceMembership,
+}))
+
+vi.mock('@/lib/credentials/oauth', () => ({
+ syncWorkspaceOAuthCredentialsForUser: vi.fn(),
+}))
+
+vi.mock('@/lib/oauth', () => ({
+ getServiceConfigByProviderId: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/atlassian-service-account', () => ({
+ AtlassianValidationError: class AtlassianValidationError extends Error {},
+}))
+
+vi.mock('@/lib/credentials/service-account-secret', () => ({
+ verifyAndBuildServiceAccountSecret: mockVerifyAndBuildServiceAccountSecret,
+ ServiceAccountSecretError: class ServiceAccountSecretError extends Error {},
+}))
+
+import { POST } from '@/app/api/credentials/route'
+
+const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555'
+
+describe('POST /api/credentials', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' },
+ })
+ mockCheckWorkspaceAccess.mockResolvedValue({
+ hasAccess: true,
+ canWrite: true,
+ canAdmin: true,
+ })
+ mockGetWorkspaceMembership.mockResolvedValue({ ownerId: 'user-1', memberUserIds: ['user-1'] })
+ })
+
+ describe('client-credential service accounts', () => {
+ it('forwards clientId, clientSecret, and orgId to the secret builder on create', async () => {
+ mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({
+ providerId: 'zoom-service-account',
+ encryptedServiceAccountKey: 'encrypted-blob',
+ displayName: 'Zoom account acct_123',
+ auditMetadata: { zoomAccountId: 'acct_123' },
+ })
+
+ const req = createMockRequest('POST', {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'zoom-client-id',
+ clientSecret: 'zoom-secret',
+ orgId: 'acct_123',
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(201)
+ expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledTimes(1)
+ expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith(
+ 'zoom-service-account',
+ expect.objectContaining({
+ clientId: 'zoom-client-id',
+ clientSecret: 'zoom-secret',
+ orgId: 'acct_123',
+ })
+ )
+ })
+
+ it('maps a verification failure to a 400 with the validation code', async () => {
+ mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce(
+ new TokenServiceAccountValidationError('invalid_credentials', 400, {
+ step: 'zoom_token_mint',
+ })
+ )
+
+ const req = createMockRequest('POST', {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'zoom-client-id',
+ clientSecret: 'zoom-secret',
+ orgId: 'acct_123',
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data).toEqual({ code: 'invalid_credentials', error: 'invalid_credentials' })
+ })
+
+ it('maps a provider outage to a 502, not a 400', async () => {
+ mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce(
+ new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'zoom_token_mint',
+ })
+ )
+
+ const req = createMockRequest('POST', {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'zoom-client-id',
+ clientSecret: 'zoom-secret',
+ orgId: 'acct_123',
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(502)
+ expect(data).toEqual({ code: 'provider_unavailable', error: 'provider_unavailable' })
+ })
+
+ it('rejects a client-credential create missing the required fields', async () => {
+ const req = createMockRequest('POST', {
+ workspaceId: WORKSPACE_ID,
+ type: 'service_account',
+ providerId: 'zoom-service-account',
+ clientId: 'zoom-client-id',
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain('clientSecret is required')
+ expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index e1708f0b48b..c3b82c7bab8 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -313,6 +313,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
id: clientCredentialId,
signingSecret,
botToken,
+ clientId,
+ clientSecret,
+ orgId,
} = parsed.data.body
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -370,6 +373,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
apiToken,
domain,
serviceAccountJson,
+ clientId,
+ clientSecret,
+ orgId,
})
resolvedProviderId = secret.providerId
resolvedAccountId = null
@@ -604,7 +610,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
upstreamStatus: error.status,
...error.logDetail,
})
- return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
+ // A provider outage is an infra failure, not a bad request — mirror the
+ // runtime token route so monitoring sees a 502, not a 400.
+ const status = error.code === 'provider_unavailable' ? 502 : 400
+ return NextResponse.json({ code: error.code, error: error.code }, { status })
}
if (error instanceof DuplicateCredentialError) {
return NextResponse.json(
diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts
index bb6a05cea88..9249cb76e69 100644
--- a/apps/sim/app/api/tools/pipedrive/get-files/route.ts
+++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts
@@ -11,6 +11,7 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
export const dynamic = 'force-dynamic'
@@ -22,6 +23,20 @@ interface PipedriveFile {
url?: string
}
+/**
+ * Whether a download URL belongs to Pipedrive. The workspace credential is
+ * only attached to Pipedrive-owned hosts — if the API ever returns an
+ * external/CDN download URL, it is fetched without credentials.
+ */
+function isPipedriveHost(url: string): boolean {
+ try {
+ const hostname = new URL(url).hostname.toLowerCase()
+ return hostname === 'pipedrive.com' || hostname.endsWith('.pipedrive.com')
+ } catch {
+ return false
+ }
+}
+
interface PipedriveApiResponse {
success: boolean
data?: PipedriveFile[]
@@ -54,7 +69,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(pipedriveGetFilesContract, request, {})
if (!parsed.success) return parsed.response
- const { accessToken, sort, limit, start, downloadFiles } = parsed.data.body
+ const { accessToken, authStyle, sort, limit, start, downloadFiles } = parsed.data.body
+ const authHeaders = getPipedriveAuthHeaders({ accessToken, authStyle })
const baseUrl = 'https://api.pipedrive.com/v1/files'
const queryParams = new URLSearchParams()
@@ -75,10 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const response = await secureFetchWithPinnedIP(apiUrl, urlValidation.resolvedIP!, {
method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: 'application/json',
- },
+ headers: authHeaders,
})
const data = (await response.json()) as PipedriveApiResponse
@@ -102,6 +115,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}> = []
if (downloadFiles) {
+ // Bare auth headers for byte downloads — no Accept: application/json.
+ const downloadAuthHeaders: Record =
+ authStyle === 'x-api-token'
+ ? { 'x-api-token': accessToken }
+ : { Authorization: `Bearer ${accessToken}` }
+
for (const file of files) {
if (!file?.url) continue
@@ -114,7 +133,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
fileUrlValidation.resolvedIP!,
{
method: 'GET',
- headers: { Authorization: `Bearer ${accessToken}` },
+ headers: isPipedriveHost(file.url) ? downloadAuthHeaders : {},
}
)
diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts
index 707a9ff9ee4..16381901228 100644
--- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts
+++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts
@@ -5,7 +5,8 @@ import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
+import { resolveCredentialAccessToken } from '@/app/api/auth/oauth/utils'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
const logger = createLogger('PipedrivePipelinesAPI')
@@ -36,7 +37,9 @@ interface PipedrivePipelinesPage {
* `PIPEDRIVE_MAX_PIPELINES_PAGES`; logs a warning rather than silently dropping
* pipelines when the cap is hit.
*/
-async function fetchAllPipelines(accessToken: string): Promise {
+async function fetchAllPipelines(
+ authHeaders: Record
+): Promise {
const pipelines: PipedrivePipeline[] = []
let start = 0
@@ -46,10 +49,7 @@ async function fetchAllPipelines(accessToken: string): Promise {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
- const accessToken = await refreshAccessTokenIfNeeded(
+ const tokenResult = await resolveCredentialAccessToken(
credential,
authz.credentialOwnerUserId,
requestId
)
- if (!accessToken) {
+ if (!tokenResult) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
@@ -124,7 +124,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
let allPipelines: PipedrivePipeline[]
try {
- allPipelines = await fetchAllPipelines(accessToken)
+ allPipelines = await fetchAllPipelines(getPipedriveAuthHeaders(tokenResult))
} catch (error) {
if (error instanceof PipedriveFetchError) {
logger.error('Failed to fetch Pipedrive pipelines', {
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
index d7ecd79e36e..2d7abb20569 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
@@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
+import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors'
import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
import {
blockTypeToIconMap,
@@ -88,14 +89,17 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
- // Vendor-accurate connect label: token-paste providers use their own noun
- // ("Add API key", "Add private app token"); only true service-account
- // providers (Google, Atlassian) say "Add service account".
- const tokenDescriptor = getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId)
+ // Vendor-accurate connect label: token-paste and client-credential
+ // providers use their own noun ("Add API key", "Add server-to-server app");
+ // only true service-account providers (Google, Atlassian) say
+ // "Add service account".
+ const nounDescriptor =
+ getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ??
+ getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId)
const serviceAccountConnectLabel = isSlackBot
? 'Set up a custom bot'
- : tokenDescriptor
- ? `Add ${tokenDescriptor.connectNoun}`
+ : nounDescriptor
+ ? `Add ${nounDescriptor.connectNoun}`
: 'Add service account'
const hasHandledConnectQueryRef = useRef(false)
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
new file mode 100644
index 00000000000..1d7f000d371
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx
@@ -0,0 +1,267 @@
+'use client'
+
+import { type ComponentType, useEffect, useState } from 'react'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ SecretInput,
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type {
+ ClientCredentialAccountDescriptor,
+ ClientCredentialAccountField,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
+import {
+ useCreateWorkspaceCredential,
+ useUpdateWorkspaceCredential,
+} from '@/hooks/queries/credentials'
+
+const logger = createLogger('ClientCredentialAccountModal')
+
+const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment."
+
+/**
+ * Maps server `error.code` values from client-credential verification (a real
+ * token mint against the provider) to user-facing messages, personalized with
+ * the provider's field labels.
+ */
+function messageForClientCredentialError(
+ err: unknown,
+ descriptor: ClientCredentialAccountDescriptor
+): string {
+ if (isApiClientError(err) && err.code) {
+ const fieldLabels = descriptor.fields.map((field) => field.label).join(', ')
+ switch (err.code) {
+ case 'invalid_credentials':
+ return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.`
+ case 'site_not_found':
+ return `We couldn't find a ${descriptor.serviceLabel} account at that host. Check the spelling of the host field and try again.`
+ case 'provider_unavailable':
+ return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
+ case 'duplicate_display_name':
+ return 'A credential with that name already exists in this workspace.'
+ default:
+ return FALLBACK_ERROR_MESSAGE
+ }
+ }
+ return FALLBACK_ERROR_MESSAGE
+}
+
+function openDocs(url: string): void {
+ window.open(url, '_blank', 'noopener,noreferrer')
+}
+
+interface ClientCredentialAccountModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ descriptor: ClientCredentialAccountDescriptor
+ serviceName: string
+ serviceIcon: ComponentType<{ className?: string }>
+ /** When set, reconnect (rotate the secrets on) this credential in place. */
+ credentialId?: string
+ initialDisplayName?: string
+ initialDescription?: string
+}
+
+/**
+ * Generic connect modal for client-credentials service accounts (Zoom
+ * Server-to-Server OAuth, Box CCG). Renders the client id, client secret, and
+ * org-identifier fields declared by the provider's
+ * {@link ClientCredentialAccountDescriptor} and submits through the same
+ * create/update credential mutations as the other service-account modals.
+ * The server verifies the triple by minting a real access token; failures are
+ * mapped from the route's `error.code`.
+ */
+export function ClientCredentialAccountModal({
+ open,
+ onOpenChange,
+ workspaceId,
+ descriptor,
+ serviceName,
+ serviceIcon: ServiceIcon,
+ credentialId,
+ initialDisplayName,
+ initialDescription,
+}: ClientCredentialAccountModalProps) {
+ const [clientId, setClientId] = useState('')
+ const [clientSecret, setClientSecret] = useState('')
+ const [orgId, setOrgId] = useState('')
+ const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
+ const [description, setDescription] = useState(initialDescription ?? '')
+ const [error, setError] = useState(null)
+
+ const createCredential = useCreateWorkspaceCredential()
+ const updateCredential = useUpdateWorkspaceCredential()
+
+ useEffect(() => {
+ if (open) return
+ setClientId('')
+ setClientSecret('')
+ setOrgId('')
+ setDisplayName(initialDisplayName ?? '')
+ setDescription(initialDescription ?? '')
+ setError(null)
+ }, [open, initialDisplayName, initialDescription])
+
+ const clientIdField = descriptor.fields.find((field) => field.id === 'clientId')
+ const clientSecretField = descriptor.fields.find((field) => field.id === 'clientSecret')
+ const orgIdField = descriptor.fields.find((field) => field.id === 'orgId')
+
+ const trimmedClientId = clientId.trim()
+ const trimmedClientSecret = clientSecret.trim()
+ const trimmedOrgId = orgId.trim()
+ const isPending = createCredential.isPending || updateCredential.isPending
+ const isDisabled = !trimmedClientId || !trimmedClientSecret || !trimmedOrgId || isPending
+
+ const hintFor = (
+ field: ClientCredentialAccountField | undefined,
+ value: string
+ ): string | undefined => {
+ if (!field?.hintPattern || !field.hintMessage || value.length === 0) return undefined
+ const normalized = field.hintNormalize ? field.hintNormalize(value) : value
+ return field.hintPattern.test(normalized) ? undefined : field.hintMessage
+ }
+
+ const handleSubmit = async () => {
+ setError(null)
+ if (isDisabled) return
+ try {
+ const secretFields = {
+ clientId: trimmedClientId,
+ clientSecret: trimmedClientSecret,
+ orgId: trimmedOrgId,
+ }
+ if (credentialId) {
+ await updateCredential.mutateAsync({
+ credentialId,
+ ...secretFields,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ } else {
+ await createCredential.mutateAsync({
+ workspaceId,
+ type: 'service_account',
+ providerId: descriptor.providerId,
+ ...secretFields,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ }
+ onOpenChange(false)
+ } catch (err: unknown) {
+ setError(messageForClientCredentialError(err, descriptor))
+ logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
+ }
+ }
+
+ return (
+
+ onOpenChange(false)}>
+ Add {serviceName} {descriptor.connectNoun}
+
+
+ {clientIdField && (
+ {
+ setClientId(value)
+ if (error) setError(null)
+ }}
+ placeholder={clientIdField.placeholder}
+ autoComplete='off'
+ required
+ hint={hintFor(clientIdField, trimmedClientId)}
+ />
+ )}
+
+ {clientSecretField && (
+
+ {
+ setClientSecret(value)
+ if (error) setError(null)
+ }}
+ placeholder={clientSecretField.placeholder}
+ name={`${descriptor.providerId}_client_secret`}
+ autoComplete='new-password'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+
+ )}
+
+ {orgIdField && (
+ {
+ setOrgId(value)
+ if (error) setError(null)
+ }}
+ placeholder={orgIdField.placeholder}
+ autoComplete='off'
+ required
+ hint={hintFor(orgIdField, trimmedOrgId)}
+ />
+ )}
+
+
+
+
+
+ {error}
+
+ onOpenChange(false)}
+ secondaryActions={[
+ {
+ label: 'Setup guide',
+ onClick: () => openDocs(descriptor.docsUrl),
+ },
+ ]}
+ primaryAction={{
+ label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`,
+ onClick: handleSubmit,
+ disabled: isDisabled,
+ }}
+ />
+
+ )
+}
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 6c8f6722601..83ae7ad48b8 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
@@ -14,6 +14,10 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isApiClientError } from '@/lib/api/client/errors'
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
+import {
+ type ClientCredentialAccountProviderId,
+ getClientCredentialAccountDescriptor,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
import {
getTokenServiceAccountDescriptor,
type TokenServiceAccountProviderId,
@@ -22,6 +26,7 @@ import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
+import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal'
import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import {
@@ -38,6 +43,7 @@ export type ServiceAccountProviderId =
| typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SLACK_CUSTOM_BOT_PROVIDER_ID
| TokenServiceAccountProviderId
+ | ClientCredentialAccountProviderId
/** Sim setup guides for each provider, docked bottom-left of each modal. */
const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account'
@@ -127,6 +133,22 @@ export function ConnectServiceAccountModal({
credentialDisplayName,
credentialDescription,
}: ConnectServiceAccountModalProps) {
+ const clientCredentialDescriptor = getClientCredentialAccountDescriptor(serviceAccountProviderId)
+ if (clientCredentialDescriptor) {
+ return (
+
+ )
+ }
const tokenDescriptor = getTokenServiceAccountDescriptor(serviceAccountProviderId)
if (tokenDescriptor) {
return (
diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts
index 95216e98f72..42df3956fb2 100644
--- a/apps/sim/blocks/blocks/zoom.ts
+++ b/apps/sim/blocks/blocks/zoom.ts
@@ -73,7 +73,7 @@ export const ZoomBlock: BlockConfig = {
id: 'userId',
title: 'User ID',
type: 'short-input',
- placeholder: 'me (or user ID/email)',
+ placeholder: 'me (OAuth only) or user ID/email',
required: true,
condition: {
field: 'operation',
@@ -625,7 +625,11 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`,
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Zoom access token' },
- userId: { type: 'string', description: 'User ID or email (use "me" for authenticated user)' },
+ userId: {
+ type: 'string',
+ description:
+ 'User ID or email address. Use "me" for the authenticated user with OAuth credentials; with a Zoom server-to-server service account, "me" is not supported — provide the target user ID or email address.',
+ },
meetingId: { type: 'string', description: 'Meeting ID' },
topic: { type: 'string', description: 'Meeting topic' },
topicUpdate: { type: 'string', description: 'Meeting topic for update' },
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index db1cd25e6ca..67146168fc1 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -140,6 +140,9 @@ export function useUpdateWorkspaceCredential() {
botToken: payload.botToken,
apiToken: payload.apiToken,
domain: payload.domain,
+ clientId: payload.clientId,
+ clientSecret: payload.clientSecret,
+ orgId: payload.orgId,
},
})
},
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index 22793dd27d5..99bfd56adfa 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -130,6 +130,9 @@ export const createCredentialBodySchema = z
id: z.string().uuid('id must be a valid UUID').optional(),
signingSecret: z.string().trim().min(1).optional(),
botToken: z.string().trim().min(1).optional(),
+ clientId: z.string().trim().min(1).max(512).optional(),
+ clientSecret: z.string().trim().min(1).max(1024).optional(),
+ orgId: z.string().trim().min(1).max(255).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
@@ -200,6 +203,10 @@ export const updateCredentialByIdBodySchema = z
/** Atlassian service-account secret rotation (reconnect). */
apiToken: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
+ /** Client-credential service-account secret rotation (reconnect). */
+ clientId: z.string().trim().min(1).max(512).optional(),
+ clientSecret: z.string().trim().min(1).max(1024).optional(),
+ orgId: z.string().trim().min(1).max(255).optional(),
})
.strict()
.refine(
@@ -210,7 +217,10 @@ export const updateCredentialByIdBodySchema = z
data.signingSecret !== undefined ||
data.botToken !== undefined ||
data.apiToken !== undefined ||
- data.domain !== undefined,
+ data.domain !== undefined ||
+ data.clientId !== undefined ||
+ data.clientSecret !== undefined ||
+ data.orgId !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts
index 1c447b700f3..6cd7f11b47e 100644
--- a/apps/sim/lib/api/contracts/oauth-connections.ts
+++ b/apps/sim/lib/api/contracts/oauth-connections.ts
@@ -88,6 +88,7 @@ const oauthTokenResponseSchema = z.object({
instanceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
+ authStyle: z.enum(['x-api-token']).optional(),
})
export const oauthTokenGetContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/tools/pipedrive.ts b/apps/sim/lib/api/contracts/tools/pipedrive.ts
index 454a6a77099..d1d33f86067 100644
--- a/apps/sim/lib/api/contracts/tools/pipedrive.ts
+++ b/apps/sim/lib/api/contracts/tools/pipedrive.ts
@@ -27,6 +27,7 @@ export const pipedriveGetFilesResponseSchema = z.object({
export const pipedriveGetFilesBodySchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
+ authStyle: z.enum(['x-api-token']).optional(),
sort: z.enum(['id', 'update_time']).optional().nullable(),
limit: z.string().optional().nullable(),
start: z.string().optional().nullable(),
diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts
new file mode 100644
index 00000000000..fab342a0c38
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts
@@ -0,0 +1,211 @@
+/**
+ * Client-safe descriptors for client-credentials service-account providers.
+ *
+ * A client-credential account is a `service_account`-type credential where a
+ * workspace admin pastes an OAuth client id + client secret + provider org
+ * identifier instead of a long-lived token. Unlike the token-paste family
+ * (whose stored secret IS the access token), these credentials mint a
+ * short-lived access token on demand via the provider's client-credentials
+ * grant (Zoom Server-to-Server OAuth, Box CCG). This module holds only
+ * UI/contract metadata (field lists, labels, docs links); the server-side
+ * minting registry lives in `@/lib/credentials/client-credential-accounts/server`.
+ */
+
+/** Discriminator stored inside every encrypted client-credential secret blob. */
+export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const
+
+/** Contract field ids a client-credential connect modal collects. */
+export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId'
+
+export interface ClientCredentialAccountField {
+ id: ClientCredentialAccountFieldId
+ label: string
+ placeholder: string
+ /** Rendered with SecretInput and never echoed back. */
+ secret: boolean
+ /** Soft-format hint shown while the current value doesn't match `hintPattern`. */
+ hintPattern?: RegExp
+ hintMessage?: string
+ /**
+ * Normalizes the raw value before testing `hintPattern`, mirroring the
+ * server-side normalization so values the server accepts (e.g. a pasted
+ * `https://` URL) don't show a false format hint.
+ */
+ hintNormalize?: (value: string) => string
+}
+
+export interface ClientCredentialAccountDescriptor {
+ /** Stable credential `providerId` (`-service-account`). */
+ providerId: string
+ /** Human service label used in modal copy and error messages (e.g. "Zoom"). */
+ serviceLabel: string
+ /**
+ * Short vendor-accurate noun for connect-surface labels ("Add {connectNoun}").
+ * Uses the vendor's own vocabulary for the credential.
+ */
+ connectNoun: string
+ fields: ClientCredentialAccountField[]
+ /** Sim setup guide, docked bottom-left of the connect modal. */
+ docsUrl: string
+ /** Optional one-line caveat rendered in the connect modal. */
+ helpText?: string
+}
+
+export const ZOOM_SERVICE_ACCOUNT_PROVIDER_ID = 'zoom-service-account' as const
+export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
+export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
+
+export type ClientCredentialAccountProviderId =
+ | typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
+
+/**
+ * Allowed My Domain host shapes: one org label (optionally with a
+ * `--sandboxName` suffix), an optional partition label (sandbox, develop,
+ * scratch, demo, patch, trailblaze, free), then `my.salesforce.com`. Covers
+ * production (`org.my.salesforce.com`), sandboxes
+ * (`org--sbx.sandbox.my.salesforce.com`), and Developer Edition
+ * (`org-dev-ed.develop.my.salesforce.com`). Gov/mil TLDs are excluded.
+ */
+export const SALESFORCE_MY_DOMAIN_HOST_REGEX =
+ /^[a-z0-9][a-z0-9-]*(--[a-z0-9]+)?(\.(sandbox|develop|scratch|demo|patch|trailblaze|free))?\.my\.salesforce\.com$/
+
+/**
+ * Normalizes a pasted My Domain value to a bare host: strips the protocol,
+ * any path/query/fragment, and trailing content, then lowercases. Shared by
+ * the connect modal's format hint and the server-side minter so both judge
+ * the same normalized value.
+ */
+export function normalizeSalesforceMyDomainHost(rawHost: string): string {
+ return rawHost
+ .trim()
+ .replace(/^https?:\/\//i, '')
+ .replace(/[/?#].*$/, '')
+ .toLowerCase()
+}
+
+export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
+ ClientCredentialAccountProviderId,
+ ClientCredentialAccountDescriptor
+> = {
+ [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Zoom',
+ connectNoun: 'server-to-server app',
+ fields: [
+ {
+ id: 'clientId',
+ label: 'Client ID',
+ placeholder: 'Client ID from the App Credentials page',
+ secret: false,
+ },
+ {
+ id: 'clientSecret',
+ label: 'Client secret',
+ placeholder: 'Paste the client secret',
+ secret: true,
+ },
+ {
+ id: 'orgId',
+ label: 'Account ID',
+ placeholder: 'Account ID from the App Credentials page',
+ secret: false,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account',
+ helpText:
+ "Copy all three values from the Server-to-Server OAuth app's App Credentials page — the Account ID there is not the account number shown in the Zoom web portal. The app must be activated before tokens can be issued.",
+ },
+ [BOX_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: BOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Box',
+ connectNoun: 'service account',
+ fields: [
+ {
+ id: 'clientId',
+ label: 'Client ID',
+ placeholder: 'Client ID from Configuration > OAuth 2.0 Credentials',
+ secret: false,
+ },
+ {
+ id: 'clientSecret',
+ label: 'Client secret',
+ placeholder: 'Paste the client secret',
+ secret: true,
+ },
+ {
+ id: 'orgId',
+ label: 'Enterprise ID',
+ placeholder: '1234567',
+ secret: false,
+ hintPattern: /^\d+$/,
+ hintMessage: 'Box Enterprise IDs are numeric.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/box-service-account',
+ helpText:
+ 'A Box admin must authorize the app in the Admin Console first, and the Service Account only sees folders it has been invited to as a collaborator.',
+ },
+ [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Salesforce',
+ connectNoun: 'integration user app',
+ fields: [
+ {
+ id: 'clientId',
+ label: 'Consumer key',
+ placeholder: "Consumer Key from the Connected App's Manage Consumer Details page",
+ secret: false,
+ },
+ {
+ id: 'clientSecret',
+ label: 'Consumer secret',
+ placeholder: 'Paste the consumer secret',
+ secret: true,
+ },
+ {
+ id: 'orgId',
+ label: 'My Domain host',
+ placeholder: 'yourorg.my.salesforce.com',
+ secret: false,
+ hintPattern: SALESFORCE_MY_DOMAIN_HOST_REGEX,
+ hintNormalize: normalizeSalesforceMyDomainHost,
+ hintMessage:
+ 'Expected a My Domain host like yourorg.my.salesforce.com, yourorg--sbx.sandbox.my.salesforce.com, or yourorg-dev-ed.develop.my.salesforce.com.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account',
+ helpText:
+ 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.',
+ },
+}
+
+/**
+ * Required contract fields per client-credential provider, consumed by the
+ * `createCredentialBodySchema` superRefine so validation errors name the exact
+ * missing field. Derived from each descriptor's field list.
+ */
+export const CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: Record<
+ string,
+ ClientCredentialAccountFieldId[]
+> = Object.fromEntries(
+ Object.values(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS).map((descriptor) => [
+ descriptor.providerId,
+ descriptor.fields.map((field) => field.id),
+ ])
+)
+
+export function isClientCredentialAccountProviderId(
+ value: string | null | undefined
+): value is ClientCredentialAccountProviderId {
+ return Boolean(value && Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, value))
+}
+
+export function getClientCredentialAccountDescriptor(
+ providerId: string | null | undefined
+): ClientCredentialAccountDescriptor | undefined {
+ return isClientCredentialAccountProviderId(providerId)
+ ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[providerId]
+ : undefined
+}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts
new file mode 100644
index 00000000000..f380e8d8742
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts
@@ -0,0 +1,245 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
+
+const TOKEN_URL = 'https://api.box.com/oauth2/token'
+const CURRENT_USER_URL = 'https://api.box.com/2.0/users/me'
+
+const FIELDS = { clientId: 'box-cid', clientSecret: 'box-secret', orgId: '1234567' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+function htmlResponse(status: number, body: string): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => body,
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+function expectMintCall(): void {
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(TOKEN_URL)
+ expect(init.method).toBe('POST')
+ expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
+ const body = new URLSearchParams(init.body as string)
+ expect(body.get('grant_type')).toBe('client_credentials')
+ expect(body.get('client_id')).toBe('box-cid')
+ expect(body.get('client_secret')).toBe('box-secret')
+ expect(body.get('box_subject_type')).toBe('enterprise')
+ expect(body.get('box_subject_id')).toBe('1234567')
+}
+
+function expectIdentityCall(): void {
+ const [url, init] = mockFetch.mock.calls[1]
+ expect(url).toBe(CURRENT_USER_URL)
+ expect(init.headers.Authorization).toBe('Bearer box-access')
+}
+
+describe('mintBoxServiceAccountToken', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('mints a token and resolves the Service Account identity via users/me', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 }))
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ name: 'Sim Automation',
+ login: 'AutomationUser_123_abc@boxdevedition.com',
+ })
+ )
+
+ const result = await mintBoxServiceAccountToken(FIELDS)
+
+ expect(result).toEqual({
+ accessToken: 'box-access',
+ expiresInSeconds: 3600,
+ identity: {
+ displayName: 'Sim Automation',
+ auditMetadata: {
+ boxEnterpriseId: '1234567',
+ boxServiceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com',
+ },
+ storedMetadata: {
+ enterpriseId: '1234567',
+ serviceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com',
+ },
+ },
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectMintCall()
+ expectIdentityCall()
+ })
+
+ it('still succeeds with a fallback identity when users/me fails', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 2400 }))
+ .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' }))
+
+ const result = await mintBoxServiceAccountToken(FIELDS)
+
+ expect(result.accessToken).toBe('box-access')
+ expect(result.expiresInSeconds).toBe(2400)
+ expect(result.identity).toEqual({
+ displayName: 'Box enterprise 1234567',
+ auditMetadata: { boxEnterpriseId: '1234567' },
+ })
+ })
+
+ it('still succeeds when the identity request itself throws', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 }))
+ .mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ const result = await mintBoxServiceAccountToken(FIELDS)
+
+ expect(result.accessToken).toBe('box-access')
+ expect(result.identity?.displayName).toBe('Box enterprise 1234567')
+ })
+
+ it('throws invalid_credentials on 400 invalid_client', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ error: 'invalid_client',
+ error_description: 'The client credentials are not valid',
+ })
+ )
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({ hint: 'client credentials are not valid' }),
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws invalid_credentials with an authorization hint on 400 unauthorized_client', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ error: 'unauthorized_client',
+ error_description: 'This app is not authorized by the enterprise admin',
+ })
+ )
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({
+ hint: 'app is not authorized by the enterprise admin (Platform Apps Manager)',
+ }),
+ })
+ })
+
+ it('flags a wrong app type on the grant-type variant of unauthorized_client', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ error: 'unauthorized_client',
+ error_description: 'The grant type is unauthorized for this client_id',
+ })
+ )
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ logDetail: expect.objectContaining({
+ hint: 'app was created as user authentication (OAuth 2.0) instead of Server Authentication',
+ }),
+ })
+ })
+
+ it('throws invalid_credentials on 400 invalid_grant', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ error: 'invalid_grant',
+ error_description: 'Grant credentials are invalid',
+ })
+ )
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({
+ hint: 'Client ID, Client Secret, and Enterprise ID do not all belong to the same app/enterprise, or the app has not been authorized in the Admin Console',
+ }),
+ })
+ })
+
+ it('skips the identity lookup when skipIdentity is set', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })
+ )
+
+ const result = await mintBoxServiceAccountToken(FIELDS, { skipIdentity: true })
+
+ expect(result).toEqual({ accessToken: 'box-access', expiresInSeconds: 3600 })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' }))
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ })
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(503, 'unavailable'))
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 503,
+ })
+ })
+
+ it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page'))
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when the success body is missing access_token', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { token_type: 'bearer' }))
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on a network error', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts
new file mode 100644
index 00000000000..720631d072d
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts
@@ -0,0 +1,162 @@
+import type {
+ ClientCredentialAccountFields,
+ ClientCredentialAccountIdentity,
+ ClientCredentialAccountMintOptions,
+ ClientCredentialAccountMintResult,
+} from '@/lib/credentials/client-credential-accounts/server'
+import {
+ fetchProvider,
+ isTransientProviderStatus,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+} from '@/lib/credentials/token-service-accounts/errors'
+
+const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token'
+const BOX_CURRENT_USER_URL = 'https://api.box.com/2.0/users/me'
+
+interface BoxTokenResponse {
+ access_token?: string
+ expires_in?: number
+}
+
+interface BoxCurrentUserResponse {
+ name?: string
+ login?: string
+}
+
+/**
+ * Maps a parsed Box OAuth2Error body to an operator-facing hint for server
+ * logs. Box reports every CCG auth failure as HTTP 400 with an `error` field,
+ * so the field value is the only way to distinguish bad credentials from a
+ * not-yet-authorized or wrongly-typed app.
+ */
+function boxErrorHint(body: string): string | undefined {
+ try {
+ const parsed = JSON.parse(body) as { error?: string; error_description?: string }
+ switch (parsed.error) {
+ case 'invalid_client':
+ return 'client credentials are not valid'
+ case 'unauthorized_client':
+ return /grant type/i.test(parsed.error_description ?? '')
+ ? 'app was created as user authentication (OAuth 2.0) instead of Server Authentication'
+ : 'app is not authorized by the enterprise admin (Platform Apps Manager)'
+ case 'invalid_grant':
+ return 'Client ID, Client Secret, and Enterprise ID do not all belong to the same app/enterprise, or the app has not been authorized in the Admin Console'
+ default:
+ return undefined
+ }
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Best-effort identity lookup for the app's Service Account user. A failure
+ * never fails the mint — the caller falls back to an Enterprise-ID-derived
+ * display name.
+ */
+async function fetchBoxServiceAccountIdentity(
+ accessToken: string,
+ orgId: string
+): Promise {
+ const fallback: ClientCredentialAccountIdentity = {
+ displayName: `Box enterprise ${orgId}`,
+ auditMetadata: { boxEnterpriseId: orgId },
+ }
+ try {
+ const res = await fetchProvider(
+ BOX_CURRENT_USER_URL,
+ { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
+ 'box_identity'
+ )
+ if (!res.ok) return fallback
+ const user = await parseProviderJson(res, 'box_identity')
+ const login = typeof user.login === 'string' && user.login ? user.login : undefined
+ const name = typeof user.name === 'string' && user.name ? user.name : undefined
+ return {
+ displayName: name ?? login ?? fallback.displayName,
+ auditMetadata: {
+ boxEnterpriseId: orgId,
+ ...(login ? { boxServiceAccountLogin: login } : {}),
+ },
+ storedMetadata: {
+ enterpriseId: orgId,
+ ...(login ? { serviceAccountLogin: login } : {}),
+ },
+ }
+ } catch {
+ return fallback
+ }
+}
+
+/**
+ * Mints a Box access token via the Client Credentials Grant, authenticating
+ * as the Platform App's Service Account (`box_subject_type=enterprise`).
+ * Credentials ride in the form body (client_secret_post); tokens live ~1 hour
+ * (honor the response's `expires_in`), carry no refresh token, and are
+ * re-minted rather than refreshed.
+ *
+ * Box reports every CCG auth failure as HTTP 400 with an OAuth2Error body
+ * (`invalid_client`, `unauthorized_client`, `invalid_grant`), so 4xx maps to
+ * `invalid_credentials` — except transient 429/408 throttling statuses, which
+ * map to `provider_unavailable` alongside 5xx/network failures (never blame
+ * the credentials for provider-side throttling).
+ */
+export async function mintBoxServiceAccountToken(
+ fields: ClientCredentialAccountFields,
+ options?: ClientCredentialAccountMintOptions
+): Promise {
+ const res = await fetchProvider(
+ BOX_TOKEN_URL,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: fields.clientId,
+ client_secret: fields.clientSecret,
+ box_subject_type: 'enterprise',
+ box_subject_id: fields.orgId,
+ }).toString(),
+ },
+ 'box_token_mint'
+ )
+
+ if (!res.ok) {
+ const body = await readProviderErrorSnippet(res)
+ if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) {
+ const hint = boxErrorHint(body)
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'box_token_mint',
+ body,
+ ...(hint ? { hint } : {}),
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'box_token_mint',
+ body,
+ })
+ }
+
+ const payload = await parseProviderJson(res, 'box_token_mint')
+ if (typeof payload.access_token !== 'string' || !payload.access_token) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'box_token_mint',
+ reason: 'token response missing access_token',
+ })
+ }
+
+ const accessToken = payload.access_token
+ const expiresInSeconds = typeof payload.expires_in === 'number' ? payload.expires_in : 3600
+
+ if (options?.skipIdentity) {
+ return { accessToken, expiresInSeconds }
+ }
+
+ return {
+ accessToken,
+ expiresInSeconds,
+ identity: await fetchBoxServiceAccountIdentity(accessToken, fields.orgId),
+ }
+}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts
new file mode 100644
index 00000000000..6e0aad65f22
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts
@@ -0,0 +1,320 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
+
+const HOST = 'yourorg.my.salesforce.com'
+const TOKEN_URL = `https://${HOST}/services/oauth2/token`
+const INSTANCE_URL = 'https://yourorg.my.salesforce.com'
+
+const FIELDS = { clientId: 'test-consumer-key', clientSecret: 'sf-secret', orgId: HOST }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+function htmlResponse(status: number, body: string): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => body,
+ } as unknown as Response
+}
+
+/** Builds a structurally valid unsigned JWT carrying the given exp claim. */
+function jwtWithExp(expSeconds: number): string {
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
+ return `${encode({ alg: 'none' })}.${encode({ exp: expSeconds })}.sig`
+}
+
+const mockFetch = vi.fn()
+
+function expectMintCall(expectedUrl = TOKEN_URL): void {
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(expectedUrl)
+ expect(init.method).toBe('POST')
+ expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
+ const body = new URLSearchParams(init.body as string)
+ expect(body.get('grant_type')).toBe('client_credentials')
+ expect(body.get('client_id')).toBe('test-consumer-key')
+ expect(body.get('client_secret')).toBe('sf-secret')
+ expect(body.get('scope')).toBeNull()
+}
+
+describe('mintSalesforceServiceAccountToken', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('mints against the My Domain token endpoint and derives identity from userinfo', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ access_token: 'sf-access',
+ instance_url: INSTANCE_URL,
+ token_type: 'Bearer',
+ token_format: 'opaque',
+ scope: 'api',
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ name: 'Integration User',
+ preferred_username: 'integration@yourorg.com',
+ organization_id: '00Dxx0000000001EAA',
+ })
+ )
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS)
+
+ expect(result).toEqual({
+ accessToken: 'sf-access',
+ expiresInSeconds: 600,
+ instanceUrl: INSTANCE_URL,
+ grantedScopes: ['api'],
+ identity: {
+ displayName: 'Integration User',
+ auditMetadata: {
+ salesforceMyDomainHost: HOST,
+ salesforceOrgId: '00Dxx0000000001EAA',
+ salesforceRunAsUsername: 'integration@yourorg.com',
+ },
+ storedMetadata: {
+ myDomainHost: HOST,
+ instanceUrl: INSTANCE_URL,
+ orgId: '00Dxx0000000001EAA',
+ runAsUsername: 'integration@yourorg.com',
+ grantedScopes: 'api',
+ },
+ },
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectMintCall()
+ expect(mockFetch.mock.calls[1][0]).toBe(`${INSTANCE_URL}/services/oauth2/userinfo`)
+ expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe('Bearer sf-access')
+ })
+
+ it('normalizes a pasted URL-style host before minting', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-access' }))
+ .mockResolvedValueOnce(jsonResponse(403, {}))
+
+ const result = await mintSalesforceServiceAccountToken({
+ ...FIELDS,
+ orgId: 'https://YourOrg.My.Salesforce.com/some/path?x=1',
+ })
+
+ expectMintCall()
+ expect(result.instanceUrl).toBe(INSTANCE_URL)
+ })
+
+ it.each([
+ 'yourorg--uat.sandbox.my.salesforce.com',
+ 'yourorg-dev-ed.develop.my.salesforce.com',
+ 'yourorg--sbx.scratch.my.salesforce.com',
+ ])('accepts the %s partitioned My Domain host', async (host) => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-access' }))
+ .mockResolvedValueOnce(jsonResponse(403, {}))
+
+ await mintSalesforceServiceAccountToken({ ...FIELDS, orgId: host })
+
+ expectMintCall(`https://${host}/services/oauth2/token`)
+ })
+
+ it.each([
+ 'evil.com',
+ 'login.salesforce.com',
+ 'test.salesforce.com',
+ 'yourorg.my.salesforce.com.evil.com',
+ 'my.salesforce.com',
+ 'yourorg.evil.my.salesforce.com',
+ 'evil.com/yourorg.my.salesforce.com',
+ 'evil.com#yourorg.my.salesforce.com',
+ 'yourorg.my.salesforce.mil',
+ 'yourorg.my.salesforce.com@evil.com',
+ 'yourorg.my.salesforce.com:8443',
+ 'yourorg.my.salesforce.com.',
+ '',
+ ])('rejects the host %j before any outbound fetch', async (host) => {
+ await expect(
+ mintSalesforceServiceAccountToken({ ...FIELDS, orgId: host })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'site_not_found',
+ status: 400,
+ logDetail: expect.objectContaining({ step: 'host_validation' }),
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['invalid_client_id', 'consumer key or consumer secret is invalid'],
+ ['invalid_client', 'consumer key or consumer secret is invalid'],
+ [
+ 'invalid_grant',
+ 'Client Credentials Flow is not enabled on the Connected App, no "Run As" user is configured, or the Run As user is deactivated/frozen',
+ ],
+ ])('throws invalid_credentials with a hint on 400 %s', async (error, hint) => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(400, { error, error_description: 'nope' }))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({ hint }),
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('skips the userinfo lookup when skipIdentity is set', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL, scope: 'api' })
+ )
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS, { skipIdentity: true })
+
+ expect(result).toEqual({
+ accessToken: 'sf-access',
+ expiresInSeconds: 600,
+ instanceUrl: INSTANCE_URL,
+ grantedScopes: ['api'],
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' }))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ })
+ })
+
+ it('maps a DNS-resolution failure on the My Domain host to site_not_found', async () => {
+ const dnsError = new TypeError('fetch failed')
+ ;(dnsError as { cause?: unknown }).cause = Object.assign(new Error('getaddrinfo ENOTFOUND'), {
+ code: 'ENOTFOUND',
+ })
+ mockFetch.mockRejectedValueOnce(dnsError)
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'site_not_found',
+ status: 400,
+ logDetail: expect.objectContaining({
+ reason: 'host does not resolve — check the My Domain host',
+ }),
+ })
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(503, 'maintenance'))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 503,
+ })
+ })
+
+ it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page'))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when the success body is missing access_token', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { instance_url: INSTANCE_URL }))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on a network error', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('falls back to a host-derived identity when the userinfo call fails', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL })
+ )
+ .mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS)
+
+ expect(result.accessToken).toBe('sf-access')
+ expect(result.identity).toEqual({
+ displayName: `Salesforce ${HOST}`,
+ auditMetadata: { salesforceMyDomainHost: HOST },
+ storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL },
+ })
+ })
+
+ it('ignores a non-Salesforce instance_url and falls back to the validated host', async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse(200, { access_token: 'sf-access', instance_url: 'https://evil.com' })
+ )
+ .mockResolvedValueOnce(jsonResponse(403, {}))
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS)
+
+ expect(result.instanceUrl).toBe(INSTANCE_URL)
+ expect(mockFetch.mock.calls[1][0]).toBe(`${INSTANCE_URL}/services/oauth2/userinfo`)
+ })
+
+ it('clamps the cache TTL to the exp claim when the token is a JWT', async () => {
+ const exp = Math.floor(Date.now() / 1000) + 300
+ mockFetch
+ .mockResolvedValueOnce(
+ jsonResponse(200, {
+ access_token: jwtWithExp(exp),
+ instance_url: INSTANCE_URL,
+ token_format: 'jwt',
+ })
+ )
+ .mockResolvedValueOnce(jsonResponse(403, {}))
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS)
+
+ expect(result.expiresInSeconds).toBeGreaterThan(230)
+ expect(result.expiresInSeconds).toBeLessThanOrEqual(240)
+ })
+
+ it('caps a long-lived JWT exp at the conservative 10-minute TTL', async () => {
+ const exp = Math.floor(Date.now() / 1000) + 7200
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(200, { access_token: jwtWithExp(exp) }))
+ .mockResolvedValueOnce(jsonResponse(403, {}))
+
+ const result = await mintSalesforceServiceAccountToken(FIELDS)
+
+ expect(result.expiresInSeconds).toBe(600)
+ })
+})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts
new file mode 100644
index 00000000000..8928cf9d9c6
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts
@@ -0,0 +1,271 @@
+import {
+ normalizeSalesforceMyDomainHost,
+ SALESFORCE_MY_DOMAIN_HOST_REGEX,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
+import type {
+ ClientCredentialAccountFields,
+ ClientCredentialAccountIdentity,
+ ClientCredentialAccountMintOptions,
+ ClientCredentialAccountMintResult,
+} from '@/lib/credentials/client-credential-accounts/server'
+import {
+ fetchProvider,
+ isTransientProviderStatus,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+} from '@/lib/credentials/token-service-accounts/errors'
+
+/**
+ * Salesforce never returns `expires_in` on client-credentials responses —
+ * opaque-token lifetime is the run-as user's org session timeout (2h default,
+ * 15min minimum), unknowable from the response. Cache conservatively for 10
+ * minutes (safely below the 15-minute floor) and rely on re-minting.
+ */
+const SALESFORCE_TOKEN_TTL_SECONDS = 600
+
+interface SalesforceTokenResponse {
+ access_token?: string
+ instance_url?: string
+ scope?: string
+}
+
+interface SalesforceUserinfoResponse {
+ name?: string
+ preferred_username?: string
+ organization_id?: string
+}
+
+/**
+ * Maps a parsed Salesforce token-endpoint error body to an operator-facing
+ * hint for server logs. Salesforce returns HTTP 400 for every credential
+ * problem, so `error`/`error_description` are the only way to tell a bad
+ * consumer key/secret apart from a misconfigured Connected App.
+ */
+function salesforceErrorHint(body: string): string | undefined {
+ try {
+ const parsed = JSON.parse(body) as { error?: string; error_description?: string }
+ const error = parsed.error ?? ''
+ if (error.startsWith('invalid_client')) {
+ return 'consumer key or consumer secret is invalid'
+ }
+ if (error === 'invalid_grant') {
+ return 'Client Credentials Flow is not enabled on the Connected App, no "Run As" user is configured, or the Run As user is deactivated/frozen'
+ }
+ return undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Reads the `exp` claim (epoch seconds) from a JWT-format access token.
+ * Orgs with "Issue JSON Web Token (JWT)-Based Access Tokens" enabled embed
+ * the hard expiry in the token itself (never in an `expires_in` field).
+ * @returns The exp claim, or undefined when the token is not a decodable JWT
+ */
+function decodeJwtExpSeconds(token: string): number | undefined {
+ const parts = token.split('.')
+ if (parts.length !== 3) return undefined
+ try {
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
+ exp?: number
+ }
+ return typeof payload.exp === 'number' ? payload.exp : undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Derives a conservative cache TTL: 10 minutes for opaque tokens (lifetime is
+ * unknowable from the response), or `exp - 60s` clamped to at most 10 minutes
+ * when the token is a JWT carrying a hard expiry.
+ */
+function salesforceTokenTtlSeconds(accessToken: string): number {
+ const exp = decodeJwtExpSeconds(accessToken)
+ if (exp === undefined) return SALESFORCE_TOKEN_TTL_SECONDS
+ const remaining = exp - Math.floor(Date.now() / 1000) - 60
+ return Math.min(Math.max(remaining, 0), SALESFORCE_TOKEN_TTL_SECONDS)
+}
+
+/**
+ * Best-effort identity lookup for the run-as integration user via the
+ * standard userinfo endpoint. A failure never fails the mint — the caller
+ * falls back to a host-derived display name.
+ */
+async function fetchSalesforceIdentity(
+ accessToken: string,
+ instanceUrl: string,
+ host: string
+): Promise {
+ const fallback: ClientCredentialAccountIdentity = {
+ displayName: `Salesforce ${host}`,
+ auditMetadata: { salesforceMyDomainHost: host },
+ storedMetadata: { myDomainHost: host, instanceUrl },
+ }
+ try {
+ const res = await fetchProvider(
+ `${instanceUrl}/services/oauth2/userinfo`,
+ { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
+ 'salesforce_identity'
+ )
+ if (!res.ok) return fallback
+ const user = await parseProviderJson(res, 'salesforce_identity')
+ const username =
+ typeof user.preferred_username === 'string' && user.preferred_username
+ ? user.preferred_username
+ : undefined
+ const name = typeof user.name === 'string' && user.name ? user.name : undefined
+ const orgId =
+ typeof user.organization_id === 'string' && user.organization_id
+ ? user.organization_id
+ : undefined
+ return {
+ displayName: name ?? username ?? fallback.displayName,
+ auditMetadata: {
+ salesforceMyDomainHost: host,
+ ...(orgId ? { salesforceOrgId: orgId } : {}),
+ ...(username ? { salesforceRunAsUsername: username } : {}),
+ },
+ storedMetadata: {
+ myDomainHost: host,
+ instanceUrl,
+ ...(orgId ? { orgId } : {}),
+ ...(username ? { runAsUsername: username } : {}),
+ },
+ }
+ } catch {
+ return fallback
+ }
+}
+
+/**
+ * Mints a Salesforce access token via the OAuth 2.0 Client Credentials Flow
+ * against the org's own My Domain token endpoint
+ * (`https://{host}/services/oauth2/token` — login.salesforce.com hard-rejects
+ * this grant). Credentials ride in the form body (client_secret_post) with no
+ * scope parameter (Salesforce doesn't support scopes on this endpoint; grants
+ * come from the Connected App config). The host is SSRF-guarded against the
+ * My Domain allowlist before any outbound fetch.
+ *
+ * Salesforce reports every credential/configuration failure as HTTP 400 with
+ * `{ error, error_description }` (invalid_client_id, invalid_client,
+ * invalid_grant), so 4xx maps to `invalid_credentials` — except transient
+ * 429/408 throttling statuses, which map to `provider_unavailable` alongside
+ * 5xx/network failures (never blame the credentials for provider-side
+ * throttling). A host that fails DNS resolution maps to `site_not_found`
+ * (the pasted My Domain host is wrong, not Salesforce down). The response
+ * carries no `expires_in` and no refresh token — see
+ * {@link SALESFORCE_TOKEN_TTL_SECONDS}.
+ */
+export async function mintSalesforceServiceAccountToken(
+ fields: ClientCredentialAccountFields,
+ options?: ClientCredentialAccountMintOptions
+): Promise {
+ const host = normalizeSalesforceMyDomainHost(fields.orgId)
+ if (!SALESFORCE_MY_DOMAIN_HOST_REGEX.test(host)) {
+ throw new TokenServiceAccountValidationError('site_not_found', 400, {
+ step: 'host_validation',
+ host,
+ reason:
+ 'host is not a Salesforce My Domain host (expected *.my.salesforce.com, *.sandbox.my.salesforce.com, or *.develop.my.salesforce.com — never login.salesforce.com)',
+ })
+ }
+
+ const res = await fetchProvider(
+ `https://${host}/services/oauth2/token`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: fields.clientId,
+ client_secret: fields.clientSecret,
+ }).toString(),
+ },
+ 'salesforce_token_mint',
+ {
+ dnsFailureCode: 'site_not_found',
+ dnsFailureReason: 'host does not resolve — check the My Domain host',
+ }
+ )
+
+ if (!res.ok) {
+ const body = await readProviderErrorSnippet(res)
+ if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) {
+ const hint = salesforceErrorHint(body)
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'salesforce_token_mint',
+ host,
+ body,
+ ...(hint ? { hint } : {}),
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'salesforce_token_mint',
+ host,
+ body,
+ })
+ }
+
+ const payload = await parseProviderJson(res, 'salesforce_token_mint')
+ if (typeof payload.access_token !== 'string' || !payload.access_token) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'salesforce_token_mint',
+ host,
+ reason: 'token response missing access_token',
+ })
+ }
+
+ // The response's instance_url is authoritative (it can differ from the
+ // stored My Domain host on enhanced-domain orgs), but it is only trusted for
+ // follow-up fetches when it is an https *.salesforce.com URL — otherwise the
+ // validated My Domain host is used.
+ const instanceUrl = normalizeInstanceUrl(payload.instance_url, host)
+ const grantedScopes =
+ typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(Boolean) : undefined
+
+ if (options?.skipIdentity) {
+ return {
+ accessToken: payload.access_token,
+ expiresInSeconds: salesforceTokenTtlSeconds(payload.access_token),
+ instanceUrl,
+ grantedScopes,
+ }
+ }
+
+ const identity = await fetchSalesforceIdentity(payload.access_token, instanceUrl, host)
+ if (grantedScopes?.length) {
+ identity.storedMetadata = {
+ ...identity.storedMetadata,
+ grantedScopes: grantedScopes.join(' '),
+ }
+ }
+
+ return {
+ accessToken: payload.access_token,
+ expiresInSeconds: salesforceTokenTtlSeconds(payload.access_token),
+ instanceUrl,
+ grantedScopes,
+ identity,
+ }
+}
+
+/**
+ * Validates the mint response's `instance_url` (https, `*.salesforce.com`
+ * host, no path) and normalizes away any trailing slash; falls back to the
+ * already-SSRF-validated My Domain host when the value is absent or fails
+ * validation.
+ */
+function normalizeInstanceUrl(rawInstanceUrl: string | undefined, host: string): string {
+ const fallback = `https://${host}`
+ if (typeof rawInstanceUrl !== 'string' || !rawInstanceUrl) return fallback
+ try {
+ const url = new URL(rawInstanceUrl)
+ if (url.protocol !== 'https:' || !url.hostname.endsWith('.salesforce.com')) return fallback
+ return `https://${url.hostname}`
+ } catch {
+ return fallback
+ }
+}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts
new file mode 100644
index 00000000000..dcfd2822a14
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts
@@ -0,0 +1,199 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
+
+const TOKEN_URL = 'https://zoom.us/oauth/token'
+
+const FIELDS = { clientId: 'zoom-cid', clientSecret: 'zoom-secret', orgId: 'AbCdEf123' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+function htmlResponse(status: number, body: string): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => body,
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+function expectMintCall(): void {
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(TOKEN_URL)
+ expect(init.method).toBe('POST')
+ expect(init.headers.Authorization).toBe(
+ `Basic ${Buffer.from('zoom-cid:zoom-secret').toString('base64')}`
+ )
+ expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
+ const body = new URLSearchParams(init.body as string)
+ expect(body.get('grant_type')).toBe('account_credentials')
+ expect(body.get('account_id')).toBe('AbCdEf123')
+}
+
+describe('mintZoomServiceAccountToken', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns the minted token, granted scopes, and derived identity on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ access_token: 'zoom-access',
+ token_type: 'bearer',
+ expires_in: 3600,
+ scope: 'meeting:read:meeting:admin user:read:user:admin',
+ api_url: 'https://api.zoom.us',
+ })
+ )
+
+ const result = await mintZoomServiceAccountToken(FIELDS)
+
+ expect(result).toEqual({
+ accessToken: 'zoom-access',
+ expiresInSeconds: 3600,
+ grantedScopes: ['meeting:read:meeting:admin', 'user:read:user:admin'],
+ identity: {
+ displayName: 'Zoom account AbCdEf123',
+ auditMetadata: { zoomAccountId: 'AbCdEf123', zoomClientId: 'zoom-cid' },
+ storedMetadata: {
+ apiUrl: 'https://api.zoom.us',
+ grantedScopes: 'meeting:read:meeting:admin user:read:user:admin',
+ },
+ },
+ })
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ expectMintCall()
+ })
+
+ it('omits the identity when skipIdentity is set', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ access_token: 'zoom-access',
+ expires_in: 3600,
+ scope: 'meeting:read:meeting:admin',
+ })
+ )
+
+ const result = await mintZoomServiceAccountToken(FIELDS, { skipIdentity: true })
+
+ expect(result).toEqual({
+ accessToken: 'zoom-access',
+ expiresInSeconds: 3600,
+ grantedScopes: ['meeting:read:meeting:admin'],
+ })
+ })
+
+ it('omits storedMetadata and scopes when the response lacks api_url and scope', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { access_token: 'zoom-access', expires_in: 1800 })
+ )
+
+ const result = await mintZoomServiceAccountToken(FIELDS)
+
+ expect(result.accessToken).toBe('zoom-access')
+ expect(result.expiresInSeconds).toBe(1800)
+ expect(result.grantedScopes).toBeUndefined()
+ expect(result.identity?.storedMetadata).toBeUndefined()
+ })
+
+ it('throws invalid_credentials on 400 invalid_client', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, { error: 'invalid_client', reason: 'Invalid client_id or client_secret' })
+ )
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({ hint: 'invalid client_id or client_secret' }),
+ })
+ })
+
+ it('throws invalid_credentials with a misconfig hint on 400 unsupported grant type', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, { reason: 'unsupported grant type', error: 'invalid_request' })
+ )
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 400,
+ logDetail: expect.objectContaining({
+ hint: 'app is not a Server-to-Server OAuth app',
+ }),
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'unauthorized' }))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' }))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 429,
+ })
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(503, 'unavailable'))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 503,
+ })
+ })
+
+ it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
+ mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page'))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when the success body is missing access_token', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { token_type: 'bearer' }))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on a network error', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts
new file mode 100644
index 00000000000..978409ae0ee
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts
@@ -0,0 +1,131 @@
+import type {
+ ClientCredentialAccountFields,
+ ClientCredentialAccountMintOptions,
+ ClientCredentialAccountMintResult,
+} from '@/lib/credentials/client-credential-accounts/server'
+import {
+ fetchProvider,
+ isTransientProviderStatus,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+} from '@/lib/credentials/token-service-accounts/errors'
+
+const ZOOM_TOKEN_URL = 'https://zoom.us/oauth/token'
+
+interface ZoomTokenResponse {
+ access_token?: string
+ expires_in?: number
+ scope?: string
+ api_url?: string
+}
+
+/**
+ * Maps a parsed Zoom token-endpoint error body to an operator-facing hint for
+ * server logs. Zoom returns HTTP 400 for every credential problem, so the
+ * `error`/`reason` fields are the only way to tell them apart.
+ */
+function zoomErrorHint(body: string): string | undefined {
+ try {
+ const parsed = JSON.parse(body) as { error?: string; reason?: string }
+ const haystack = `${parsed.error ?? ''} ${parsed.reason ?? ''}`.toLowerCase()
+ if (haystack.includes('invalid_client')) {
+ return 'invalid client_id or client_secret'
+ }
+ if (haystack.includes('unsupported grant type')) {
+ return 'app is not a Server-to-Server OAuth app'
+ }
+ return undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Mints a Zoom Server-to-Server OAuth access token via the
+ * `account_credentials` grant: POST https://zoom.us/oauth/token with HTTP
+ * Basic auth (client id/secret) and `account_id` = the S2S app's Account ID.
+ * Tokens live one hour, there is no refresh token, and Zoom allows multiple
+ * concurrently valid tokens — re-mint instead of refreshing.
+ *
+ * Zoom reports every credential failure as HTTP 400 (invalid_client, bad
+ * account_id, unsupported grant type, deactivated app), so 4xx maps to
+ * `invalid_credentials` — except transient 429/408 throttling statuses, which
+ * map to `provider_unavailable` alongside 5xx/network failures (never blame
+ * the credentials for provider-side throttling).
+ */
+export async function mintZoomServiceAccountToken(
+ fields: ClientCredentialAccountFields,
+ options?: ClientCredentialAccountMintOptions
+): Promise {
+ const basicAuth = Buffer.from(`${fields.clientId}:${fields.clientSecret}`).toString('base64')
+ const res = await fetchProvider(
+ ZOOM_TOKEN_URL,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${basicAuth}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'account_credentials',
+ account_id: fields.orgId,
+ }).toString(),
+ },
+ 'zoom_token_mint'
+ )
+
+ if (!res.ok) {
+ const body = await readProviderErrorSnippet(res)
+ if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) {
+ const hint = zoomErrorHint(body)
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'zoom_token_mint',
+ body,
+ ...(hint ? { hint } : {}),
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'zoom_token_mint',
+ body,
+ })
+ }
+
+ const payload = await parseProviderJson(res, 'zoom_token_mint')
+ if (typeof payload.access_token !== 'string' || !payload.access_token) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'zoom_token_mint',
+ reason: 'token response missing access_token',
+ })
+ }
+
+ const grantedScopes =
+ typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(Boolean) : undefined
+
+ if (options?.skipIdentity) {
+ return {
+ accessToken: payload.access_token,
+ expiresInSeconds: typeof payload.expires_in === 'number' ? payload.expires_in : 3600,
+ grantedScopes,
+ }
+ }
+
+ const storedMetadata: Record = {}
+ if (typeof payload.api_url === 'string' && payload.api_url) {
+ storedMetadata.apiUrl = payload.api_url
+ }
+ if (grantedScopes?.length) {
+ storedMetadata.grantedScopes = grantedScopes.join(' ')
+ }
+
+ return {
+ accessToken: payload.access_token,
+ expiresInSeconds: typeof payload.expires_in === 'number' ? payload.expires_in : 3600,
+ grantedScopes,
+ identity: {
+ displayName: `Zoom account ${fields.orgId}`,
+ auditMetadata: { zoomAccountId: fields.orgId, zoomClientId: fields.clientId },
+ ...(Object.keys(storedMetadata).length > 0 ? { storedMetadata } : {}),
+ },
+ }
+}
diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts
new file mode 100644
index 00000000000..d0ded4e6801
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE } from '@/lib/credentials/client-credential-accounts/descriptors'
+import { parseClientCredentialAccountSecretBlob } from '@/lib/credentials/client-credential-accounts/server'
+
+const MALFORMED = 'Stored client-credential service-account secret is malformed'
+
+function blob(overrides: Record = {}): string {
+ return JSON.stringify({
+ type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
+ providerId: 'zoom-service-account',
+ clientId: 'cid',
+ clientSecret: 'secret',
+ orgId: 'org',
+ ...overrides,
+ })
+}
+
+describe('parseClientCredentialAccountSecretBlob', () => {
+ it('returns the parsed blob when it matches the expected provider', () => {
+ const parsed = parseClientCredentialAccountSecretBlob(blob(), 'zoom-service-account')
+ expect(parsed.clientId).toBe('cid')
+ expect(parsed.orgId).toBe('org')
+ })
+
+ it('throws the clean malformed error on a non-JSON payload (not a raw SyntaxError)', () => {
+ expect(() =>
+ parseClientCredentialAccountSecretBlob('not json {', 'zoom-service-account')
+ ).toThrow(MALFORMED)
+ })
+
+ it('rejects a blob whose providerId does not match the credential row', () => {
+ expect(() => parseClientCredentialAccountSecretBlob(blob(), 'box-service-account')).toThrow(
+ MALFORMED
+ )
+ })
+
+ it('rejects a blob with the wrong discriminator type', () => {
+ expect(() =>
+ parseClientCredentialAccountSecretBlob(
+ blob({ type: 'token_service_account' }),
+ 'zoom-service-account'
+ )
+ ).toThrow(MALFORMED)
+ })
+
+ it('rejects a blob missing a required secret field', () => {
+ expect(() =>
+ parseClientCredentialAccountSecretBlob(blob({ clientSecret: '' }), 'zoom-service-account')
+ ).toThrow(MALFORMED)
+ })
+
+ it('throws the clean malformed error on a JSON-null payload', () => {
+ expect(() => parseClientCredentialAccountSecretBlob('null', 'zoom-service-account')).toThrow(
+ MALFORMED
+ )
+ })
+})
diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts
new file mode 100644
index 00000000000..1e92d09d5b1
--- /dev/null
+++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts
@@ -0,0 +1,125 @@
+import {
+ BOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
+ type ClientCredentialAccountProviderId,
+ isClientCredentialAccountProviderId,
+ SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
+ ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
+import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
+import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
+import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
+
+/** Raw fields a client-credential minter receives (already trimmed). */
+export interface ClientCredentialAccountFields {
+ clientId: string
+ clientSecret: string
+ /** Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, Salesforce My Domain host). */
+ orgId: string
+}
+
+/** Identity derived from a successful mint, used at connect time. */
+export interface ClientCredentialAccountIdentity {
+ /** Default display name when the user didn't provide one. */
+ displayName: string
+ /** Non-secret identifiers recorded in the audit log (e.g. account/enterprise id). */
+ auditMetadata: Record
+ /**
+ * Non-secret metadata persisted inside the encrypted blob alongside the
+ * credentials (e.g. regional API host, service-account login) for debugging.
+ */
+ storedMetadata?: Record
+}
+
+/** Result of a successful client-credentials token mint. */
+export interface ClientCredentialAccountMintResult {
+ accessToken: string
+ expiresInSeconds: number
+ /**
+ * Provider API base URL the minted token must be used against (Salesforce
+ * `instance_url`), forwarded to tools alongside the token.
+ */
+ instanceUrl?: string
+ /** Scopes granted to the app, when the provider reports them. */
+ grantedScopes?: string[]
+ identity?: ClientCredentialAccountIdentity
+}
+
+/** Options controlling how much work a mint performs. */
+export interface ClientCredentialAccountMintOptions {
+ /**
+ * Skips the best-effort identity lookup (extra provider round-trip on Box
+ * and Salesforce). Execution-time token resolution discards `identity`, so
+ * it passes `skipIdentity: true`; connect-time verification keeps the
+ * lookup for the display name and audit metadata.
+ */
+ skipIdentity?: boolean
+}
+
+export type ClientCredentialAccountMinter = (
+ fields: ClientCredentialAccountFields,
+ options?: ClientCredentialAccountMintOptions
+) => Promise
+
+/**
+ * Server-side minting registry for client-credential providers. Keys must stay
+ * in lockstep with `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS` — a descriptor
+ * without a minter fails loudly at create time. The same minter runs at
+ * connect time (verification) and at execution time (token resolution).
+ */
+const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
+ ClientCredentialAccountProviderId,
+ ClientCredentialAccountMinter
+> = {
+ [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomServiceAccountToken,
+ [BOX_SERVICE_ACCOUNT_PROVIDER_ID]: mintBoxServiceAccountToken,
+ [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
+}
+
+export function getClientCredentialAccountMinter(
+ providerId: string
+): ClientCredentialAccountMinter | undefined {
+ return isClientCredentialAccountProviderId(providerId)
+ ? CLIENT_CREDENTIAL_ACCOUNT_MINTERS[providerId]
+ : undefined
+}
+
+/**
+ * Shape of the decrypted secret blob persisted for client-credential accounts.
+ * `providerId` is stored inside the blob so a mismatched credential row fails
+ * loudly at resolution time instead of minting against another provider.
+ */
+export interface ClientCredentialAccountSecretBlob {
+ type: typeof CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE
+ providerId: string
+ clientId: string
+ clientSecret: string
+ orgId: string
+ metadata?: Record
+}
+
+export function parseClientCredentialAccountSecretBlob(
+ decrypted: string,
+ expectedProviderId: string
+): ClientCredentialAccountSecretBlob {
+ const malformed = new Error('Stored client-credential service-account secret is malformed')
+ let parsed: ClientCredentialAccountSecretBlob
+ try {
+ parsed = JSON.parse(decrypted) as ClientCredentialAccountSecretBlob
+ } catch {
+ throw malformed
+ }
+ if (typeof parsed !== 'object' || parsed === null) {
+ throw malformed
+ }
+ if (
+ parsed.type !== CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE ||
+ parsed.providerId !== expectedProviderId ||
+ !parsed.clientId ||
+ !parsed.clientSecret ||
+ !parsed.orgId
+ ) {
+ throw malformed
+ }
+ return parsed
+}
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index b2812a7e6b6..4e7900ded35 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -49,6 +49,10 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams {
/** Atlassian service-account secret rotation (reconnect). */
apiToken?: string
domain?: string
+ /** Client-credential service-account secret rotation (reconnect). */
+ clientId?: string
+ clientSecret?: string
+ orgId?: string
}
export interface PerformCredentialResult {
@@ -125,7 +129,10 @@ export async function performUpdateCredential(
params.signingSecret !== undefined ||
params.botToken !== undefined ||
params.apiToken !== undefined ||
- params.domain !== undefined
+ params.domain !== undefined ||
+ params.clientId !== undefined ||
+ params.clientSecret !== undefined ||
+ params.orgId !== undefined
let rotatedSlackBotUserId: string | undefined
if (hasRotationSecret && access.credential.type === 'service_account') {
try {
@@ -136,6 +143,9 @@ export async function performUpdateCredential(
botToken: params.botToken,
apiToken: params.apiToken,
domain: params.domain,
+ clientId: params.clientId,
+ clientSecret: params.clientSecret,
+ orgId: params.orgId,
}
)
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts
index ea505ddd9c4..e636edde4cd 100644
--- a/apps/sim/lib/credentials/service-account-fields.ts
+++ b/apps/sim/lib/credentials/service-account-fields.ts
@@ -1,3 +1,4 @@
+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 {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -12,6 +13,9 @@ export type ServiceAccountFieldId =
| 'serviceAccountJson'
| 'signingSecret'
| 'botToken'
+ | 'clientId'
+ | 'clientSecret'
+ | 'orgId'
/**
* Required create-body fields per service-account provider — the client-safe
@@ -19,7 +23,8 @@ export type ServiceAccountFieldId =
* (Server-side builders re-derive their own requirements: token-paste
* providers from descriptor fields, bespoke providers inline.) Token-paste
* providers contribute their entries from
- * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are
+ * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`, client-credential providers from
+ * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are
* declared here.
*/
export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = {
@@ -27,6 +32,7 @@ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record ({
- // Identity encryption so tests can read back the JSON blob.
- mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })),
- mockFetchSlackTeamId: vi.fn(),
- mockValidateAtlassian: vi.fn(),
- mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()),
- }))
+const {
+ mockEncryptSecret,
+ mockFetchSlackTeamId,
+ mockValidateAtlassian,
+ mockNormalizeDomain,
+ mockClientCredentialMinter,
+} = vi.hoisted(() => ({
+ // Identity encryption so tests can read back the JSON blob.
+ mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })),
+ mockFetchSlackTeamId: vi.fn(),
+ mockValidateAtlassian: vi.fn(),
+ mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()),
+ mockClientCredentialMinter: vi.fn(),
+}))
vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret }))
vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId }))
@@ -33,6 +39,12 @@ vi.mock('@/lib/api/contracts/credentials', () => ({
vi.mock('@/lib/api/server', () => ({
getValidationErrorMessage: (_error: unknown, fallback: string) => fallback,
}))
+vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({
+ getClientCredentialAccountMinter: (providerId: string) =>
+ providerId === 'zoom-service-account' || providerId === 'box-service-account'
+ ? mockClientCredentialMinter
+ : undefined,
+}))
import {
ServiceAccountSecretError,
@@ -139,6 +151,74 @@ describe('verifyAndBuildServiceAccountSecret', () => {
).rejects.toThrow('Unsupported service-account provider')
})
+ it('dispatches a client-credential provider to the minter and encrypts the blob', async () => {
+ mockClientCredentialMinter.mockResolvedValue({
+ accessToken: 'minted',
+ expiresInSeconds: 3600,
+ identity: {
+ displayName: 'Zoom account acc-1',
+ auditMetadata: { zoomAccountId: 'acc-1' },
+ storedMetadata: { apiUrl: 'https://api.zoom.us' },
+ },
+ })
+ const result = await verifyAndBuildServiceAccountSecret('zoom-service-account', {
+ clientId: ' cid ',
+ clientSecret: ' csec ',
+ orgId: ' acc-1 ',
+ })
+ expect(result.providerId).toBe('zoom-service-account')
+ expect(result.displayName).toBe('Zoom account acc-1')
+ expect(result.auditMetadata).toEqual({ zoomAccountId: 'acc-1' })
+ expect(mockClientCredentialMinter).toHaveBeenCalledWith({
+ clientId: 'cid',
+ clientSecret: 'csec',
+ orgId: 'acc-1',
+ })
+ const blob = JSON.parse(result.encryptedServiceAccountKey)
+ expect(blob).toEqual({
+ type: 'client_credential_account',
+ providerId: 'zoom-service-account',
+ clientId: 'cid',
+ clientSecret: 'csec',
+ orgId: 'acc-1',
+ metadata: { apiUrl: 'https://api.zoom.us' },
+ })
+ })
+
+ it('falls back to a label-derived display name when the mint has no identity', async () => {
+ mockClientCredentialMinter.mockResolvedValue({ accessToken: 'minted', expiresInSeconds: 3600 })
+ const result = await verifyAndBuildServiceAccountSecret('box-service-account', {
+ clientId: 'cid',
+ clientSecret: 'csec',
+ orgId: '999',
+ })
+ expect(result.displayName).toBe('Box 999')
+ expect(result.auditMetadata).toEqual({})
+ const blob = JSON.parse(result.encryptedServiceAccountKey)
+ expect(blob.metadata).toBeUndefined()
+ })
+
+ it('throws when client-credential required fields are missing, without minting', async () => {
+ await expect(
+ verifyAndBuildServiceAccountSecret('zoom-service-account', {
+ clientId: 'cid',
+ clientSecret: 'csec',
+ })
+ ).rejects.toBeInstanceOf(ServiceAccountSecretError)
+ expect(mockClientCredentialMinter).not.toHaveBeenCalled()
+ })
+
+ it('propagates a failed client-credential mint', async () => {
+ mockClientCredentialMinter.mockRejectedValue(new Error('invalid_credentials'))
+ await expect(
+ verifyAndBuildServiceAccountSecret('box-service-account', {
+ clientId: 'cid',
+ clientSecret: 'bad',
+ orgId: '999',
+ })
+ ).rejects.toThrow('invalid_credentials')
+ })
+
it('rejects prototype-chain providerIds with a validation error, not a TypeError', async () => {
for (const providerId of ['__proto__', 'constructor', 'toString']) {
await expect(
diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts
index a14f0d4b5ca..3562658a1d6 100644
--- a/apps/sim/lib/credentials/service-account-secret.ts
+++ b/apps/sim/lib/credentials/service-account-secret.ts
@@ -6,6 +6,15 @@ import {
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
+import {
+ CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
+ getClientCredentialAccountDescriptor,
+ isClientCredentialAccountProviderId,
+} from '@/lib/credentials/client-credential-accounts/descriptors'
+import {
+ type ClientCredentialAccountSecretBlob,
+ getClientCredentialAccountMinter,
+} from '@/lib/credentials/client-credential-accounts/server'
import {
getTokenServiceAccountDescriptor,
isTokenServiceAccountProviderId,
@@ -31,6 +40,9 @@ export interface ServiceAccountSecretFields {
apiToken?: string
domain?: string
serviceAccountJson?: string
+ clientId?: string
+ clientSecret?: string
+ orgId?: string
}
export interface ServiceAccountSecretResult {
@@ -200,6 +212,52 @@ async function buildTokenServiceAccountSecret(
}
}
+/**
+ * Builds a client-credential service-account secret (OAuth client id/secret +
+ * provider org identifier) for any provider registered in
+ * `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS`: verifies the triple by minting a
+ * real access token via the provider's registered minter (also capturing the
+ * derived identity for the display name and audit log), then persists the raw
+ * fields in the encrypted blob so execution-time resolution can re-mint.
+ */
+async function buildClientCredentialAccountSecret(
+ providerId: string,
+ fields: ServiceAccountSecretFields
+): Promise {
+ const descriptor = getClientCredentialAccountDescriptor(providerId)
+ const minter = getClientCredentialAccountMinter(providerId)
+ if (!descriptor || !minter) {
+ throw new ServiceAccountSecretError(
+ `No minter registered for service-account provider ${providerId}`
+ )
+ }
+ const clientId = fields.clientId?.trim()
+ const clientSecret = fields.clientSecret?.trim()
+ const orgId = fields.orgId?.trim()
+ if (!clientId || !clientSecret || !orgId) {
+ const required = descriptor.fields.map((field) => field.id).join(', ')
+ throw new ServiceAccountSecretError(
+ `${required} are required for ${descriptor.serviceLabel} service account credentials`
+ )
+ }
+ const mint = await minter({ clientId, clientSecret, orgId })
+ const blob: ClientCredentialAccountSecretBlob = {
+ type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
+ providerId,
+ clientId,
+ clientSecret,
+ orgId,
+ ...(mint.identity?.storedMetadata ? { metadata: mint.identity.storedMetadata } : {}),
+ }
+ const { encrypted } = await encryptSecret(JSON.stringify(blob))
+ return {
+ providerId,
+ encryptedServiceAccountKey: encrypted,
+ displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${orgId}`,
+ auditMetadata: mint.identity?.auditMetadata ?? {},
+ }
+}
+
type ServiceAccountSecretBuilder = (
fields: ServiceAccountSecretFields
) => Promise
@@ -219,8 +277,9 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record`); `x-api-token` providers (e.g.
+ * Pipedrive) send the token in an `x-api-token` header instead, and the
+ * token route surfaces this so tool header builders can switch schemes.
+ */
+ authStyle?: 'bearer' | 'x-api-token'
}
export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const
@@ -60,6 +67,7 @@ export const WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'webflow-service-account' as
export const TRELLO_SERVICE_ACCOUNT_PROVIDER_ID = 'trello-service-account' as const
export const CALCOM_SERVICE_ACCOUNT_PROVIDER_ID = 'calcom-service-account' as const
export const WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID = 'wealthbox-service-account' as const
+export const PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'pipedrive-service-account' as const
const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i
@@ -76,6 +84,7 @@ export type TokenServiceAccountProviderId =
| typeof TRELLO_SERVICE_ACCOUNT_PROVIDER_ID
| typeof CALCOM_SERVICE_ACCOUNT_PROVIDER_ID
| typeof WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID
export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
TokenServiceAccountProviderId,
@@ -304,6 +313,24 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
helpText:
'Trial accounts cannot use the Wealthbox API; contact Wealthbox support if API Access is missing from your Settings.',
},
+ [PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Pipedrive',
+ tokenNoun: 'API token',
+ connectNoun: 'API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API token',
+ placeholder: 'Paste personal API token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/pipedrive-service-account',
+ helpText:
+ 'Each Pipedrive user has one API token per company — regenerating it breaks every integration using the old value, and API-token traffic gets lower rate limits than OAuth.',
+ authStyle: 'x-api-token',
+ },
}
/**
diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts
index 1c8e1114531..abf0b003b96 100644
--- a/apps/sim/lib/credentials/token-service-accounts/errors.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts
@@ -23,22 +23,55 @@ export class TokenServiceAccountValidationError extends Error {
const ERROR_SNIPPET_MAX_LENGTH = 500
+/**
+ * Transient statuses a provider token/verification endpoint can return that
+ * say nothing about the submitted credentials (throttling, request timeout) —
+ * they must map to `provider_unavailable`, never `invalid_credentials`.
+ */
+export function isTransientProviderStatus(status: number): boolean {
+ return status === 408 || status === 429
+}
+
+export interface FetchProviderOptions {
+ /**
+ * Validation code thrown when the host does not resolve (`ENOTFOUND` only —
+ * the transient `EAI_AGAIN` stays `provider_unavailable`). For user-supplied
+ * hosts (e.g. a Salesforce My Domain), a non-resolving host means the pasted
+ * host is wrong — not that the provider is down — so callers map it to
+ * `site_not_found`.
+ */
+ dnsFailureCode?: TokenServiceAccountValidationCode
+ /** Log-detail reason accompanying a DNS-resolution failure. */
+ 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.
+ * 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
export async function fetchProvider(
url: string,
init: RequestInit,
- step: string
+ step: string,
+ options?: FetchProviderOptions
): Promise {
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) })
- } catch {
+ } catch (error) {
+ const causeCode = (error as { cause?: { code?: unknown } })?.cause?.code
+ // Only ENOTFOUND proves the host doesn't exist; EAI_AGAIN is a transient
+ // resolver failure and stays provider_unavailable.
+ if (options?.dnsFailureCode && causeCode === 'ENOTFOUND') {
+ throw new TokenServiceAccountValidationError(options.dnsFailureCode, 400, {
+ step,
+ reason: options.dnsFailureReason ?? 'host does not resolve',
+ })
+ }
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
step,
reason: 'network error reaching provider',
diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts
index c0a2ed16d1d..a5381f7546e 100644
--- a/apps/sim/lib/credentials/token-service-accounts/server.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/server.ts
@@ -8,6 +8,7 @@ import {
LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
MONDAY_SERVICE_ACCOUNT_PROVIDER_ID,
NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
+ PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID,
SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
type TokenServiceAccountProviderId,
@@ -23,6 +24,7 @@ import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-a
import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear'
import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday'
import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion'
+import { validatePipedriveServiceAccount } from '@/lib/credentials/token-service-accounts/validators/pipedrive'
import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify'
import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello'
import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox'
@@ -74,6 +76,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
[TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: validateTrelloServiceAccount,
[CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalcomServiceAccount,
[WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: validateWealthboxServiceAccount,
+ [PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePipedriveServiceAccount,
}
export function getTokenServiceAccountValidator(
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts
new file mode 100644
index 00000000000..1e73129649b
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { validatePipedriveServiceAccount } from '@/lib/credentials/token-service-accounts/validators/pipedrive'
+
+const ME_URL = 'https://api.pipedrive.com/v1/users/me'
+
+const FIELDS = { apiToken: 'pd-test-token' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+describe('validatePipedriveServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on success using the x-api-token header', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ success: true,
+ data: {
+ id: 42,
+ name: 'Jane Doe',
+ company_id: 777,
+ company_name: 'Acme Inc',
+ company_domain: 'acme',
+ },
+ })
+ )
+
+ const result = await validatePipedriveServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'Jane Doe (Acme Inc)',
+ auditMetadata: { pipedriveCompanyId: '777' },
+ storedMetadata: { userId: '42', companyId: '777', companyDomain: 'acme' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(ME_URL)
+ expect(init.headers['x-api-token']).toBe(FIELDS.apiToken)
+ expect(init.headers.Authorization).toBeUndefined()
+ })
+
+ it('falls back to a company display name when the user name is missing', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, { success: true, data: { id: 42, company_id: 777 } })
+ )
+
+ const result = await validatePipedriveServiceAccount(FIELDS)
+
+ expect(result.displayName).toBe('Pipedrive company 777')
+ expect(result.storedMetadata).toEqual({ userId: '42', companyId: '777' })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, {}))
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable on 429 (rate limited, token not blamed)', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(429, { error: 'Rate limit exceeded' }))
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 429,
+ })
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' }))
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 503,
+ })
+ })
+
+ it('throws provider_unavailable on a non-JSON response body', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => 'proxy error',
+ } as unknown as Response)
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when the response lacks a user id', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { success: true, data: {} }))
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ logDetail: { step: 'users_me', reason: 'missing user id' },
+ })
+ })
+
+ it('throws provider_unavailable on a network error', async () => {
+ mockFetch.mockRejectedValue(new TypeError('fetch failed'))
+
+ await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ logDetail: { step: 'users_me', reason: 'network error reaching provider' },
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts
new file mode 100644
index 00000000000..3fa20e90ec6
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts
@@ -0,0 +1,82 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const USERS_ME_URL = 'https://api.pipedrive.com/v1/users/me'
+
+interface PipedriveUsersMeResponse {
+ success?: boolean
+ data?: {
+ id?: number
+ name?: string
+ company_id?: number
+ company_name?: string
+ company_domain?: string
+ }
+}
+
+/**
+ * Validates a Pipedrive personal API token via `GET /v1/users/me` with the
+ * `x-api-token` header — the exact header shape the Sim Pipedrive tools use
+ * for API-token credentials, so validation exercises the same auth path as
+ * execution. Error mapping is status-based only (the 401 body envelope is
+ * undocumented): 401/403 → `invalid_credentials`, everything else non-2xx
+ * (including 429 rate limiting) → `provider_unavailable` via the shared
+ * helpers.
+ */
+export async function validatePipedriveServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ USERS_ME_URL,
+ {
+ headers: {
+ 'x-api-token': fields.apiToken,
+ Accept: 'application/json',
+ },
+ },
+ 'users_me'
+ )
+ await throwForProviderResponse(res, 'users_me')
+
+ const payload = await parseProviderJson(res, 'users_me')
+ const user = payload.data
+ if (payload.success !== true || typeof user?.id !== 'number') {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'users_me',
+ reason: payload.success !== true ? 'success flag missing in response' : 'missing user id',
+ })
+ }
+
+ const userName = typeof user.name === 'string' && user.name ? user.name : undefined
+ const companyName =
+ typeof user.company_name === 'string' && user.company_name ? user.company_name : undefined
+ const companyId = typeof user.company_id === 'number' ? String(user.company_id) : undefined
+ const companyDomain =
+ typeof user.company_domain === 'string' && user.company_domain ? user.company_domain : undefined
+
+ const storedMetadata: Record = { userId: String(user.id) }
+ if (companyId) storedMetadata.companyId = companyId
+ if (companyDomain) storedMetadata.companyDomain = companyDomain
+
+ const displayName = userName
+ ? companyName
+ ? `${userName} (${companyName})`
+ : userName
+ : companyId
+ ? `Pipedrive company ${companyId}`
+ : 'Pipedrive API token'
+
+ return {
+ displayName,
+ auditMetadata: companyId ? { pipedriveCompanyId: companyId } : {},
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts
index 34712123df8..4cba5ce809e 100644
--- a/apps/sim/lib/integrations/oauth-service.ts
+++ b/apps/sim/lib/integrations/oauth-service.ts
@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'
+import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
@@ -40,7 +41,8 @@ function asServiceAccountProviderId(
value === 'google-service-account' ||
value === 'atlassian-service-account' ||
value === SLACK_CUSTOM_BOT_PROVIDER_ID ||
- isTokenServiceAccountProviderId(value)
+ isTokenServiceAccountProviderId(value) ||
+ isClientCredentialAccountProviderId(value)
) {
return value
}
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index d1a0bb00dbe..8d400cfdf87 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -677,6 +677,7 @@ export const OAUTH_PROVIDERS: Record = {
icon: BoxCompanyIcon,
baseProviderIcon: BoxCompanyIcon,
scopes: ['root_readwrite', 'sign_requests.readwrite'],
+ serviceAccountProviderId: 'box-service-account',
},
},
defaultService: 'box',
@@ -930,6 +931,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Pipedrive',
description: 'Manage deals, contacts, and sales pipeline in Pipedrive CRM.',
providerId: 'pipedrive',
+ serviceAccountProviderId: 'pipedrive-service-account',
icon: PipedriveIcon,
baseProviderIcon: PipedriveIcon,
scopes: [
@@ -1026,6 +1028,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Salesforce',
description: 'Access and manage your Salesforce CRM data.',
providerId: 'salesforce',
+ serviceAccountProviderId: 'salesforce-service-account',
icon: SalesforceIcon,
baseProviderIcon: SalesforceIcon,
scopes: ['api', 'refresh_token', 'openid'],
@@ -1056,6 +1059,7 @@ export const OAUTH_PROVIDERS: Record = {
'cloud_recording:read:list_recording_files',
'cloud_recording:delete:recording_file',
],
+ serviceAccountProviderId: 'zoom-service-account',
},
},
defaultService: 'zoom',
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 5e597c53422..08a40cf79b8 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -1198,6 +1198,9 @@ export async function executeTool(
if (data.domain && !contextParams.domain) {
contextParams.domain = data.domain
}
+ if (data.authStyle && !contextParams.authStyle) {
+ contextParams.authStyle = data.authStyle
+ }
logger.info(`[${requestId}] Successfully got access token for ${toolId}`)
diff --git a/apps/sim/tools/pipedrive/create_activity.ts b/apps/sim/tools/pipedrive/create_activity.ts
index 2a08c865a47..160227a47c1 100644
--- a/apps/sim/tools/pipedrive/create_activity.ts
+++ b/apps/sim/tools/pipedrive/create_activity.ts
@@ -3,6 +3,7 @@ import type {
PipedriveCreateActivityParams,
PipedriveCreateActivityResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveCreateActivity')
@@ -23,6 +24,13 @@ export const pipedriveCreateActivityTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
subject: {
type: 'string',
required: true,
@@ -82,17 +90,10 @@ export const pipedriveCreateActivityTool: ToolConfig<
request: {
url: () => 'https://api.pipedrive.com/v1/activities',
method: 'POST',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {
subject: params.subject,
diff --git a/apps/sim/tools/pipedrive/create_deal.ts b/apps/sim/tools/pipedrive/create_deal.ts
index e328f121207..a3c2b8b0d05 100644
--- a/apps/sim/tools/pipedrive/create_deal.ts
+++ b/apps/sim/tools/pipedrive/create_deal.ts
@@ -3,6 +3,7 @@ import type {
PipedriveCreateDealParams,
PipedriveCreateDealResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveCreateDeal')
@@ -23,6 +24,13 @@ export const pipedriveCreateDealTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
title: {
type: 'string',
required: true,
@@ -82,17 +90,10 @@ export const pipedriveCreateDealTool: ToolConfig<
request: {
url: () => 'https://api.pipedrive.com/api/v2/deals',
method: 'POST',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {
title: params.title,
diff --git a/apps/sim/tools/pipedrive/create_lead.ts b/apps/sim/tools/pipedrive/create_lead.ts
index 4f5b600d142..d6ed5817f44 100644
--- a/apps/sim/tools/pipedrive/create_lead.ts
+++ b/apps/sim/tools/pipedrive/create_lead.ts
@@ -3,6 +3,7 @@ import type {
PipedriveCreateLeadParams,
PipedriveCreateLeadResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveCreateLead')
@@ -28,6 +29,13 @@ export const pipedriveCreateLeadTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
title: {
type: 'string',
required: true,
@@ -81,17 +89,10 @@ export const pipedriveCreateLeadTool: ToolConfig<
request: {
url: () => 'https://api.pipedrive.com/v1/leads',
method: 'POST',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
if (!params.person_id && !params.organization_id) {
throw new Error('Either person_id or organization_id is required to create a lead')
diff --git a/apps/sim/tools/pipedrive/create_project.ts b/apps/sim/tools/pipedrive/create_project.ts
index 5ecba5e91e3..1a77aac13f6 100644
--- a/apps/sim/tools/pipedrive/create_project.ts
+++ b/apps/sim/tools/pipedrive/create_project.ts
@@ -3,6 +3,7 @@ import type {
PipedriveCreateProjectParams,
PipedriveCreateProjectResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveCreateProject')
@@ -23,6 +24,13 @@ export const pipedriveCreateProjectTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
title: {
type: 'string',
required: true,
@@ -52,17 +60,10 @@ export const pipedriveCreateProjectTool: ToolConfig<
request: {
url: () => 'https://api.pipedrive.com/v1/projects',
method: 'POST',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {
title: params.title,
diff --git a/apps/sim/tools/pipedrive/delete_lead.ts b/apps/sim/tools/pipedrive/delete_lead.ts
index 3300f8a3dff..c79d038cdac 100644
--- a/apps/sim/tools/pipedrive/delete_lead.ts
+++ b/apps/sim/tools/pipedrive/delete_lead.ts
@@ -3,6 +3,7 @@ import type {
PipedriveDeleteLeadParams,
PipedriveDeleteLeadResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveDeleteLead')
@@ -28,6 +29,13 @@ export const pipedriveDeleteLeadTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
lead_id: {
type: 'string',
required: true,
@@ -39,16 +47,7 @@ export const pipedriveDeleteLeadTool: ToolConfig<
request: {
url: (params) => `https://api.pipedrive.com/v1/leads/${params.lead_id}`,
method: 'DELETE',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/pipedrive/get_activities.ts b/apps/sim/tools/pipedrive/get_activities.ts
index af732c6329c..28a13508926 100644
--- a/apps/sim/tools/pipedrive/get_activities.ts
+++ b/apps/sim/tools/pipedrive/get_activities.ts
@@ -4,6 +4,7 @@ import type {
PipedriveGetActivitiesResponse,
} from '@/tools/pipedrive/types'
import { PIPEDRIVE_ACTIVITY_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetActivities')
@@ -24,6 +25,13 @@ export const pipedriveGetActivitiesTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
user_id: {
type: 'string',
required: false,
@@ -71,16 +79,7 @@ export const pipedriveGetActivitiesTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/pipedrive/get_all_deals.ts b/apps/sim/tools/pipedrive/get_all_deals.ts
index d94e4bee93d..97a7934f5ac 100644
--- a/apps/sim/tools/pipedrive/get_all_deals.ts
+++ b/apps/sim/tools/pipedrive/get_all_deals.ts
@@ -7,6 +7,7 @@ import {
PIPEDRIVE_DEAL_OUTPUT_PROPERTIES,
PIPEDRIVE_METADATA_OUTPUT_PROPERTIES,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetAllDeals')
@@ -27,6 +28,13 @@ export const pipedriveGetAllDealsTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
status: {
type: 'string',
required: false,
@@ -93,16 +101,7 @@ export const pipedriveGetAllDealsTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response, params?: PipedriveGetAllDealsParams) => {
diff --git a/apps/sim/tools/pipedrive/get_deal.ts b/apps/sim/tools/pipedrive/get_deal.ts
index f61a3072997..736ab56561f 100644
--- a/apps/sim/tools/pipedrive/get_deal.ts
+++ b/apps/sim/tools/pipedrive/get_deal.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import type { PipedriveGetDealParams, PipedriveGetDealResponse } from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetDeal')
@@ -17,6 +18,13 @@ export const pipedriveGetDealTool: ToolConfig `https://api.pipedrive.com/api/v2/deals/${params.deal_id}`,
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/pipedrive/get_files.ts b/apps/sim/tools/pipedrive/get_files.ts
index 7d964fcd0f0..54f8f5b0d2d 100644
--- a/apps/sim/tools/pipedrive/get_files.ts
+++ b/apps/sim/tools/pipedrive/get_files.ts
@@ -16,6 +16,13 @@ export const pipedriveGetFilesTool: ToolConfig ({
accessToken: params.accessToken,
+ authStyle: params.authStyle,
sort: params.sort,
limit: params.limit,
start: params.start,
diff --git a/apps/sim/tools/pipedrive/get_leads.ts b/apps/sim/tools/pipedrive/get_leads.ts
index eb019405901..b180a11959e 100644
--- a/apps/sim/tools/pipedrive/get_leads.ts
+++ b/apps/sim/tools/pipedrive/get_leads.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import type { PipedriveGetLeadsParams, PipedriveGetLeadsResponse } from '@/tools/pipedrive/types'
import { PIPEDRIVE_LEAD_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetLeads')
@@ -24,6 +25,13 @@ export const pipedriveGetLeadsTool: ToolConfig {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response, params) => {
diff --git a/apps/sim/tools/pipedrive/get_mail_messages.ts b/apps/sim/tools/pipedrive/get_mail_messages.ts
index a11ad61e037..c76401994f9 100644
--- a/apps/sim/tools/pipedrive/get_mail_messages.ts
+++ b/apps/sim/tools/pipedrive/get_mail_messages.ts
@@ -3,6 +3,7 @@ import type {
PipedriveGetMailMessagesParams,
PipedriveGetMailMessagesResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetMailMessages')
@@ -28,6 +29,13 @@ export const pipedriveGetMailMessagesTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
folder: {
type: 'string',
required: false,
@@ -61,16 +69,7 @@ export const pipedriveGetMailMessagesTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/pipedrive/get_mail_thread.ts b/apps/sim/tools/pipedrive/get_mail_thread.ts
index c9b05112cbb..875edfee8b4 100644
--- a/apps/sim/tools/pipedrive/get_mail_thread.ts
+++ b/apps/sim/tools/pipedrive/get_mail_thread.ts
@@ -3,6 +3,7 @@ import type {
PipedriveGetMailThreadParams,
PipedriveGetMailThreadResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetMailThread')
@@ -28,6 +29,13 @@ export const pipedriveGetMailThreadTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
thread_id: {
type: 'string',
required: true,
@@ -40,16 +48,7 @@ export const pipedriveGetMailThreadTool: ToolConfig<
url: (params) =>
`https://api.pipedrive.com/v1/mailbox/mailThreads/${params.thread_id}/mailMessages`,
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response, params) => {
diff --git a/apps/sim/tools/pipedrive/get_pipeline_deals.ts b/apps/sim/tools/pipedrive/get_pipeline_deals.ts
index 6ffc889be97..77366b1c856 100644
--- a/apps/sim/tools/pipedrive/get_pipeline_deals.ts
+++ b/apps/sim/tools/pipedrive/get_pipeline_deals.ts
@@ -3,6 +3,7 @@ import type {
PipedriveGetPipelineDealsParams,
PipedriveGetPipelineDealsResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetPipelineDeals')
@@ -23,6 +24,13 @@ export const pipedriveGetPipelineDealsTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
pipeline_id: {
type: 'string',
required: true,
@@ -62,16 +70,7 @@ export const pipedriveGetPipelineDealsTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response, params) => {
diff --git a/apps/sim/tools/pipedrive/get_pipelines.ts b/apps/sim/tools/pipedrive/get_pipelines.ts
index 7f0fc2619de..0fb404146ab 100644
--- a/apps/sim/tools/pipedrive/get_pipelines.ts
+++ b/apps/sim/tools/pipedrive/get_pipelines.ts
@@ -4,6 +4,7 @@ import type {
PipedriveGetPipelinesResponse,
} from '@/tools/pipedrive/types'
import { PIPEDRIVE_PIPELINE_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetPipelines')
@@ -24,6 +25,13 @@ export const pipedriveGetPipelinesTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
sort_by: {
type: 'string',
required: false,
@@ -64,16 +72,7 @@ export const pipedriveGetPipelinesTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/pipedrive/get_projects.ts b/apps/sim/tools/pipedrive/get_projects.ts
index 23d27d97d18..b254ecbd367 100644
--- a/apps/sim/tools/pipedrive/get_projects.ts
+++ b/apps/sim/tools/pipedrive/get_projects.ts
@@ -3,6 +3,7 @@ import type {
PipedriveGetProjectsParams,
PipedriveGetProjectsResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveGetProjects')
@@ -23,6 +24,13 @@ export const pipedriveGetProjectsTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
project_id: {
type: 'string',
required: false,
@@ -69,16 +77,7 @@ export const pipedriveGetProjectsTool: ToolConfig<
return queryString ? `${baseUrl}?${queryString}` : baseUrl
},
method: 'GET',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- }
- },
+ headers: (params) => getPipedriveAuthHeaders(params),
},
transformResponse: async (response: Response, params) => {
diff --git a/apps/sim/tools/pipedrive/types.ts b/apps/sim/tools/pipedrive/types.ts
index bdb7fafc3b4..9bcf3536b5b 100644
--- a/apps/sim/tools/pipedrive/types.ts
+++ b/apps/sim/tools/pipedrive/types.ts
@@ -1,5 +1,16 @@
import type { OutputProperty, ToolFileData, ToolResponse } from '@/tools/types'
+/**
+ * Params shared by every Pipedrive tool. `authStyle` is injected by the
+ * credential resolver when the credential is a token-paste service account
+ * whose pasted API token must be sent as `x-api-token` instead of
+ * `Authorization: Bearer`; it is absent for OAuth credentials.
+ */
+export interface PipedriveBaseParams {
+ accessToken: string
+ authStyle?: 'x-api-token'
+}
+
/**
* Output property definitions for Pipedrive API responses.
* @see https://developers.pipedrive.com/docs/api/v1
@@ -357,8 +368,7 @@ interface PipedriveMailMessage {
}
// GET All Deals
-export interface PipedriveGetAllDealsParams {
- accessToken: string
+export interface PipedriveGetAllDealsParams extends PipedriveBaseParams {
status?: string
person_id?: string
org_id?: string
@@ -383,8 +393,7 @@ export interface PipedriveGetAllDealsResponse extends ToolResponse {
}
// GET Deal
-export interface PipedriveGetDealParams {
- accessToken: string
+export interface PipedriveGetDealParams extends PipedriveBaseParams {
deal_id: string
}
@@ -398,8 +407,7 @@ export interface PipedriveGetDealResponse extends ToolResponse {
}
// CREATE Deal
-export interface PipedriveCreateDealParams {
- accessToken: string
+export interface PipedriveCreateDealParams extends PipedriveBaseParams {
title: string
value?: string
currency?: string
@@ -421,8 +429,7 @@ export interface PipedriveCreateDealResponse extends ToolResponse {
}
// UPDATE Deal
-export interface PipedriveUpdateDealParams {
- accessToken: string
+export interface PipedriveUpdateDealParams extends PipedriveBaseParams {
deal_id: string
title?: string
value?: string
@@ -441,8 +448,7 @@ export interface PipedriveUpdateDealResponse extends ToolResponse {
}
// GET Files
-export interface PipedriveGetFilesParams {
- accessToken: string
+export interface PipedriveGetFilesParams extends PipedriveBaseParams {
sort?: string
limit?: string
start?: string
@@ -462,8 +468,7 @@ export interface PipedriveGetFilesResponse extends ToolResponse {
output: PipedriveGetFilesOutput
}
-export interface PipedriveGetMailMessagesParams {
- accessToken: string
+export interface PipedriveGetMailMessagesParams extends PipedriveBaseParams {
folder?: string
limit?: string
start?: string
@@ -482,8 +487,7 @@ export interface PipedriveGetMailMessagesResponse extends ToolResponse {
}
// GET Mail Thread
-export interface PipedriveGetMailThreadParams {
- accessToken: string
+export interface PipedriveGetMailThreadParams extends PipedriveBaseParams {
thread_id: string
}
@@ -501,8 +505,7 @@ export interface PipedriveGetMailThreadResponse extends ToolResponse {
}
// GET All Pipelines
-export interface PipedriveGetPipelinesParams {
- accessToken: string
+export interface PipedriveGetPipelinesParams extends PipedriveBaseParams {
sort_by?: string
sort_direction?: string
limit?: string
@@ -522,8 +525,7 @@ export interface PipedriveGetPipelinesResponse extends ToolResponse {
}
// GET Pipeline Deals
-export interface PipedriveGetPipelineDealsParams {
- accessToken: string
+export interface PipedriveGetPipelineDealsParams extends PipedriveBaseParams {
pipeline_id: string
stage_id?: string
limit?: string
@@ -546,8 +548,7 @@ export interface PipedriveGetPipelineDealsResponse extends ToolResponse {
}
// GET All Projects (or single project if project_id provided)
-export interface PipedriveGetProjectsParams {
- accessToken: string
+export interface PipedriveGetProjectsParams extends PipedriveBaseParams {
project_id?: string
status?: string
limit?: string
@@ -568,8 +569,7 @@ export interface PipedriveGetProjectsResponse extends ToolResponse {
}
// CREATE Project
-export interface PipedriveCreateProjectParams {
- accessToken: string
+export interface PipedriveCreateProjectParams extends PipedriveBaseParams {
title: string
description?: string
start_date?: string
@@ -586,8 +586,7 @@ export interface PipedriveCreateProjectResponse extends ToolResponse {
}
// GET All Activities
-export interface PipedriveGetActivitiesParams {
- accessToken: string
+export interface PipedriveGetActivitiesParams extends PipedriveBaseParams {
user_id?: string
type?: string
done?: string
@@ -608,8 +607,7 @@ export interface PipedriveGetActivitiesResponse extends ToolResponse {
}
// CREATE Activity
-export interface PipedriveCreateActivityParams {
- accessToken: string
+export interface PipedriveCreateActivityParams extends PipedriveBaseParams {
subject: string
type: string
due_date: string
@@ -631,8 +629,7 @@ export interface PipedriveCreateActivityResponse extends ToolResponse {
}
// UPDATE Activity
-export interface PipedriveUpdateActivityParams {
- accessToken: string
+export interface PipedriveUpdateActivityParams extends PipedriveBaseParams {
activity_id: string
subject?: string
due_date?: string
@@ -652,8 +649,7 @@ export interface PipedriveUpdateActivityResponse extends ToolResponse {
}
// GET Leads
-export interface PipedriveGetLeadsParams {
- accessToken: string
+export interface PipedriveGetLeadsParams extends PipedriveBaseParams {
lead_id?: string
archived?: string
owner_id?: string
@@ -677,8 +673,7 @@ export interface PipedriveGetLeadsResponse extends ToolResponse {
}
// CREATE Lead
-export interface PipedriveCreateLeadParams {
- accessToken: string
+export interface PipedriveCreateLeadParams extends PipedriveBaseParams {
title: string
person_id?: string
organization_id?: string
@@ -699,8 +694,7 @@ export interface PipedriveCreateLeadResponse extends ToolResponse {
}
// UPDATE Lead
-export interface PipedriveUpdateLeadParams {
- accessToken: string
+export interface PipedriveUpdateLeadParams extends PipedriveBaseParams {
lead_id: string
title?: string
person_id?: string
@@ -722,8 +716,7 @@ export interface PipedriveUpdateLeadResponse extends ToolResponse {
}
// DELETE Lead
-export interface PipedriveDeleteLeadParams {
- accessToken: string
+export interface PipedriveDeleteLeadParams extends PipedriveBaseParams {
lead_id: string
}
diff --git a/apps/sim/tools/pipedrive/update_activity.ts b/apps/sim/tools/pipedrive/update_activity.ts
index d193a4e7fc9..ac269a9fd28 100644
--- a/apps/sim/tools/pipedrive/update_activity.ts
+++ b/apps/sim/tools/pipedrive/update_activity.ts
@@ -3,6 +3,7 @@ import type {
PipedriveUpdateActivityParams,
PipedriveUpdateActivityResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveUpdateActivity')
@@ -23,6 +24,13 @@ export const pipedriveUpdateActivityTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
activity_id: {
type: 'string',
required: true,
@@ -70,17 +78,10 @@ export const pipedriveUpdateActivityTool: ToolConfig<
request: {
url: (params) => `https://api.pipedrive.com/v1/activities/${params.activity_id}`,
method: 'PUT',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {}
diff --git a/apps/sim/tools/pipedrive/update_deal.ts b/apps/sim/tools/pipedrive/update_deal.ts
index a24b6a77202..a97d1e54d20 100644
--- a/apps/sim/tools/pipedrive/update_deal.ts
+++ b/apps/sim/tools/pipedrive/update_deal.ts
@@ -3,6 +3,7 @@ import type {
PipedriveUpdateDealParams,
PipedriveUpdateDealResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveUpdateDeal')
@@ -23,6 +24,13 @@ export const pipedriveUpdateDealTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
deal_id: {
type: 'string',
required: true,
@@ -64,17 +72,10 @@ export const pipedriveUpdateDealTool: ToolConfig<
request: {
url: (params) => `https://api.pipedrive.com/api/v2/deals/${params.deal_id}`,
method: 'PATCH',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {}
diff --git a/apps/sim/tools/pipedrive/update_lead.ts b/apps/sim/tools/pipedrive/update_lead.ts
index 897b4a7714b..2a02c03ee4e 100644
--- a/apps/sim/tools/pipedrive/update_lead.ts
+++ b/apps/sim/tools/pipedrive/update_lead.ts
@@ -3,6 +3,7 @@ import type {
PipedriveUpdateLeadParams,
PipedriveUpdateLeadResponse,
} from '@/tools/pipedrive/types'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('PipedriveUpdateLead')
@@ -28,6 +29,13 @@ export const pipedriveUpdateLeadTool: ToolConfig<
visibility: 'hidden',
description: 'The access token for the Pipedrive API',
},
+ authStyle: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description:
+ 'Auth scheme for the token; set by the credential resolver for API-token service accounts',
+ },
lead_id: {
type: 'string',
required: true,
@@ -87,17 +95,10 @@ export const pipedriveUpdateLeadTool: ToolConfig<
request: {
url: (params) => `https://api.pipedrive.com/v1/leads/${params.lead_id}`,
method: 'PATCH',
- headers: (params) => {
- if (!params.accessToken) {
- throw new Error('Access token is required')
- }
-
- return {
- Authorization: `Bearer ${params.accessToken}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- }
- },
+ headers: (params) => ({
+ ...getPipedriveAuthHeaders(params),
+ 'Content-Type': 'application/json',
+ }),
body: (params) => {
const body: Record = {}
diff --git a/apps/sim/tools/pipedrive/utils.test.ts b/apps/sim/tools/pipedrive/utils.test.ts
new file mode 100644
index 00000000000..89e103721f4
--- /dev/null
+++ b/apps/sim/tools/pipedrive/utils.test.ts
@@ -0,0 +1,37 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
+
+describe('getPipedriveAuthHeaders', () => {
+ it('returns Bearer headers by default (OAuth credentials)', () => {
+ expect(getPipedriveAuthHeaders({ accessToken: 'oauth-token' })).toEqual({
+ Authorization: 'Bearer oauth-token',
+ Accept: 'application/json',
+ })
+ })
+
+ it('returns x-api-token headers for API-token service accounts', () => {
+ expect(getPipedriveAuthHeaders({ accessToken: 'api-token', authStyle: 'x-api-token' })).toEqual(
+ {
+ 'x-api-token': 'api-token',
+ Accept: 'application/json',
+ }
+ )
+ })
+
+ it('ignores unknown auth styles and falls back to Bearer', () => {
+ const params = { accessToken: 'tok', authStyle: 'bearer' } as unknown as Parameters<
+ typeof getPipedriveAuthHeaders
+ >[0]
+ expect(getPipedriveAuthHeaders(params)).toEqual({
+ Authorization: 'Bearer tok',
+ Accept: 'application/json',
+ })
+ })
+
+ it('throws when the access token is missing', () => {
+ expect(() => getPipedriveAuthHeaders({ accessToken: '' })).toThrow('Access token is required')
+ })
+})
diff --git a/apps/sim/tools/pipedrive/utils.ts b/apps/sim/tools/pipedrive/utils.ts
new file mode 100644
index 00000000000..fd08dfc4fce
--- /dev/null
+++ b/apps/sim/tools/pipedrive/utils.ts
@@ -0,0 +1,25 @@
+import type { PipedriveBaseParams } from '@/tools/pipedrive/types'
+
+/**
+ * Builds the auth headers for a Pipedrive API request. OAuth access tokens use
+ * `Authorization: Bearer`; pasted personal API tokens (token-paste service
+ * accounts) must use the `x-api-token` header instead — Pipedrive documents no
+ * token-format discriminator, so the credential resolver threads an explicit
+ * `authStyle` signal through the token route into tool params. Works on both
+ * `/v1` and `/api/v2` endpoints.
+ */
+export function getPipedriveAuthHeaders(params: PipedriveBaseParams): Record {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ if (params.authStyle === 'x-api-token') {
+ return {
+ 'x-api-token': params.accessToken,
+ Accept: 'application/json',
+ }
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }
+}
diff --git a/apps/sim/tools/zoom/create_meeting.ts b/apps/sim/tools/zoom/create_meeting.ts
index 0ebba188b27..242b5cf526e 100644
--- a/apps/sim/tools/zoom/create_meeting.ts
+++ b/apps/sim/tools/zoom/create_meeting.ts
@@ -21,7 +21,7 @@ export const zoomCreateMeetingTool: ToolConfig