diff --git a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx
new file mode 100644
index 00000000000..b944379097d
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx
@@ -0,0 +1,108 @@
+---
+title: Airtable Personal Access Tokens
+description: Set up an Airtable personal access token with scoped base access to use Airtable in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Airtable personal access tokens (PATs) let your workflows authenticate to Airtable with a token that has explicit scopes and explicit base access — instead of a person's OAuth login. The token never expires on its own, and what it can touch is decided at creation time.
+
+On Airtable Enterprise Scale plans, admins can create the same tokens from a dedicated **service account** in the Admin Panel. Those tokens are wire-identical to personal access tokens and are the better choice for organization-level use: they don't stop working when an employee leaves.
+
+## Prerequisites
+
+Any Airtable user can create a personal access token for the bases they have access to. Keep in mind the token is capped by its creator's own permissions — a PAT created by a read-only collaborator cannot write, no matter which scopes it has. Create the token from an account with the access level your workflows need.
+
+For service-account tokens, you need an Enterprise Scale plan and an admin with access to the Admin Panel.
+
+## Creating the Token
+
+
+
+ Open [airtable.com/create/tokens](https://airtable.com/create/tokens) and click **Create token**
+
+ {/* TODO(screenshot): Airtable token builder page with the "Create token" button */}
+
+
+ Give the token a name (e.g. `sim-airtable-bot`)
+
+
+ Add the scopes your workflows need. The full set Sim's Airtable tools and triggers use is:
+
+ ```
+ data.records:read
+ data.records:write
+ schema.bases:read
+ user.email:read
+ webhook:manage
+ ```
+
+ Reading records needs `data.records:read`; creating, updating, upserting, and deleting records need `data.records:write`; listing bases and tables and reading base schemas need `schema.bases:read`; `user.email:read` lets Sim show the token owner's email as the credential name; and `webhook:manage` is only needed if you use Airtable triggers in Sim.
+
+ {/* TODO(screenshot): token builder scopes section with the five scopes added */}
+
+
+ Under **Access**, add the bases or entire workspaces the token should reach. The token can only see what you list here — a token with all five scopes but no base access can't read anything.
+
+ {/* TODO(screenshot): token builder access section with a workspace added */}
+
+
+ Click **Create token** and copy it when it's shown. Airtable only displays it once — if you close the dialog, you'll have to regenerate it.
+
+
+
+
+The token is bearer credentials for every base you granted. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
+
+
+### Enterprise Scale: Service Account Tokens
+
+On Enterprise Scale plans, an admin can create up to 30 service accounts (raisable via Airtable support) from the Admin Panel, each with its own name and email. From a service account's **Personal access tokens** panel, the admin creates tokens with the same scopes-plus-base-access builder shown above. The resulting token works in Sim exactly like a user PAT — same format, same `pat…` prefix, same behavior — but it belongs to the organization, not a person, so it survives employee offboarding. If your plan supports it, prefer this over a user-created PAT.
+
+
+Enterprise organizations can enable "Block API access to organization-owned bases and workspaces". If your organization uses this setting, the service account must be allowlisted or every call fails despite a valid token.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Airtable Service Account" and click it, then click **Add to Sim** and choose **Add personal access token**
+
+ {/* TODO(screenshot): Integrations page with "Airtable Service Account" in the service list */}
+
+
+ Paste the **Personal access token**, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Airtable personal access token dialog with the personal access token filled in */}
+
+
+ Click **Add personal access token**. Sim verifies the token by calling Airtable's `whoami` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+
+Validation only proves the token is real — Airtable doesn't expose a PAT's scopes or base access for inspection. A token that validates fine can still fail every tool call if you skipped a scope or forgot to grant base access. If a block returns a 403 (`INVALID_PERMISSIONS`) or an unexpected 404 (`NOT_FOUND`), check the token's scopes and base access first.
+
+
+## Using the Service Account in Workflows
+
+Add an Airtable block to your workflow. In the credential dropdown, your Airtable service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Airtable block in a workflow with the Airtable service account selected as the credential */}
+
+The block calls `api.airtable.com` using the token — exactly the same requests as the OAuth flow, so every Airtable tool works unchanged, limited to the scopes and bases you granted.
+
+
diff --git a/apps/docs/content/docs/en/integrations/asana-service-account.mdx b/apps/docs/content/docs/en/integrations/asana-service-account.mdx
new file mode 100644
index 00000000000..51501c708ba
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/asana-service-account.mdx
@@ -0,0 +1,114 @@
+---
+title: Asana Service Accounts & Personal Access Tokens
+description: Connect Asana to Sim with a service account token or a personal access token from a dedicated bot account
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Asana service accounts let your workflows authenticate as a non-human bot identity instead of a person's account. The service account has its own name and its own token, managed centrally by a super admin — no one person's OAuth consent to expire, and access can be revoked without touching anyone's personal account.
+
+There are two ways to get a token, and both work in Sim:
+
+- **Service account token** (recommended for teams) — available on Asana **Enterprise** and **Enterprise+** plans, created by a super admin in the Admin Console.
+- **Personal access token (PAT)** — available on any plan, created from any user account. For production workflows, create it from a dedicated bot account rather than a personal one.
+
+Both token types use the same format and paste into the same field in Sim.
+
+## Option A: Service Account Token (Enterprise / Enterprise+)
+
+### Prerequisites
+
+You need an Asana **super admin** on an **Enterprise** or **Enterprise+** plan. Service accounts are an organization-level feature — regular users cannot create them.
+
+### Create the Service Account
+
+
+
+ Open the Asana **Admin Console** and go to **Apps** → **Service accounts**
+
+ {/* TODO(screenshot): Asana Admin Console with Apps → Service accounts highlighted */}
+
+
+ Click **Add service account**, give it a name (e.g. `Sim Integration`) — this is the name that will appear on tasks and comments the workflows create
+
+
+ Grant the service account **Full Permissions**. Scoped service accounts (for example, ones limited to User Provisioning / SCIM) cannot call the standard Asana API and will fail when you connect them to Sim
+
+ {/* TODO(screenshot): service account permission picker with "Full Permissions" selected */}
+
+
+ Copy the token when it's shown. Asana only displays it once — if you lose it, you'll have to regenerate it — plan to update the credential in Sim at the same time, since the old token stops working once it's replaced.
+
+
+
+
+A service account with Full Permissions has org-wide access to all data in your Asana organization, including private projects. Treat the token like an admin password.
+
+
+## Option B: Personal Access Token
+
+If you're not on an Enterprise plan, a personal access token works identically on the wire. For production workflows, create a dedicated bot user account (e.g. `sim-bot@yourcompany.com`) and generate the PAT from that account — a PAT is tied to the user who created it, inherits only that user's permissions, and stops working if the user is deprovisioned.
+
+
+
+ Log in as the bot account and open the developer console at [app.asana.com/0/my-apps](https://app.asana.com/0/my-apps)
+
+ {/* TODO(screenshot): Asana developer console "My apps" page with "Create new token" button */}
+
+
+ Click **+ Create new token**, give it a name that describes its use (e.g. `Sim Integration`), and click **Create token**
+
+
+ Copy the token immediately — Asana only shows it once.
+
+
+ Add the bot account to the workspaces and projects your workflows need. A PAT can only see what its user can see.
+
+
+
+## Adding the Token to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Asana Service Account" and click it, then click **Add to Sim** and choose **Add access token**
+
+ {/* TODO(screenshot): Integrations page with "Asana Service Account" in the service list */}
+
+
+ Paste the token — service account token or personal access token, both work in the same field — and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Asana access token dialog with the access token filled in */}
+
+
+ Click **Add access token**. Sim verifies the token by calling Asana's `/users/me` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+
+If a brand-new token fails validation with an authentication error, wait a few minutes and try again — newly created service account tokens can take a short time to become active.
+
+
+The token is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add an Asana block to your workflow. In the credential dropdown, your Asana service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Asana block in a workflow with the service account selected as the credential */}
+
+The block calls the Asana API (`app.asana.com/api/1.0`) with the token as a standard Bearer credential. There's no impersonation step — the token acts as itself: the service account identity, or the bot user for a PAT.
+
+
diff --git a/apps/docs/content/docs/en/integrations/attio-service-account.mdx b/apps/docs/content/docs/en/integrations/attio-service-account.mdx
new file mode 100644
index 00000000000..efb048b172d
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/attio-service-account.mdx
@@ -0,0 +1,102 @@
+---
+title: Attio API Keys
+description: Connect Attio to Sim with a workspace API key — a workspace-level, non-expiring access token with admin-chosen scopes
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Attio workspace API keys let your workflows authenticate at the workspace level instead of through a person's account. The key is created by a workspace admin, belongs to the workspace rather than any individual, and does not expire — no one person's OAuth consent to lapse, and access can be revoked at any time by deleting the key.
+
+## Prerequisites
+
+You need an Attio **workspace admin**. Only admins can create and manage access tokens.
+
+## Creating the API Key
+
+
+
+ Open **Workspace settings** in Attio (dropdown beside your workspace name) and go to the **Developers** tab
+
+ {/* TODO(screenshot): Attio workspace settings with the Developers tab highlighted */}
+
+
+ Click **New access token** and give it a name (e.g. `Sim Integration`)
+
+
+ Select the scopes the key needs. To match everything Sim's Attio blocks can do, grant:
+
+ ```
+ record_permission (read-write)
+ object_configuration (read-write)
+ list_configuration (read-write)
+ list_entry (read-write)
+ note (read-write)
+ task (read-write)
+ comment (read-write)
+ user_management (read)
+ webhook (read-write)
+ ```
+
+ Record and object tools need `record_permission` plus `object_configuration`; list tools need `list_configuration` and `list_entry`; note, task, comment, and webhook tools need their respective scopes; member lookups need `user_management:read`.
+
+ {/* TODO(screenshot): Attio access token scope picker with the scopes above selected */}
+
+
+ Copy the key and store it somewhere safe.
+
+
+
+
+Scopes are editable after creation — if a workflow later fails with a permission error, an admin can add the missing scope to the existing key in the Developers settings without rotating it.
+
+
+
+The API key is bearer credentials for your Attio workspace. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the key at rest.
+
+
+## Adding the API Key to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Attio Service Account" and click it, then click **Add to Sim** and choose **Add API key**
+
+ {/* TODO(screenshot): Integrations page with "Attio Service Account" in the service list */}
+
+
+ Paste the API key and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Attio API key dialog with the API key filled in */}
+
+
+ Click **Add API key**. Sim verifies the key by calling Attio's `/v2/self` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The key is encrypted before being stored.
+
+
+A key with missing scopes still validates successfully — the validation endpoint is reachable with any live key. Check the scopes granted to the key in Attio: tools whose scopes are missing will fail at run time with permission errors.
+
+
+## Using the Service Account in Workflows
+
+Add an Attio block to your workflow. In the credential dropdown, your Attio service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Attio block in a workflow with the service account selected as the credential */}
+
+The block calls the Attio API (`api.attio.com/v2`) with the key as a standard Bearer credential, with whatever scopes the admin granted the key.
+
+
diff --git a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx
new file mode 100644
index 00000000000..6204ea11018
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx
@@ -0,0 +1,82 @@
+---
+title: Cal.com API Keys
+description: Set up a Cal.com API key to use Cal.com in Sim workflows without OAuth
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Cal.com service account credentials let your workflows authenticate to Cal.com with a pasted API key instead of a personal OAuth connection. The key carries the full permissions of the Cal.com user who created it, and it stays valid until it expires or is revoked — no OAuth consent to renew.
+
+## Prerequisites
+
+You need a Cal.com account (cal.com cloud). Any user can create API keys from their own settings — no admin role required.
+
+
+Cal.com API keys are tied to the user who creates them. For production workflows, create the key from a dedicated service login (e.g. `sim-bot@yourcompany.com`) rather than a personal account — the credential then survives any individual employee leaving, and bookings and event types the workflows manage belong to the bot user.
+
+
+## Creating the API Key
+
+
+
+ Log in to Cal.com and go to **Settings** → **Developer** → **API keys** (direct link: [app.cal.com/settings/developer/api-keys](https://app.cal.com/settings/developer/api-keys))
+
+ {/* TODO(screenshot): Cal.com settings Developer section with the API keys page open */}
+
+
+ Click **+ Add**, give the key a name (e.g. `sim-workflows`), and set its expiry. Choose a non-expiring key if the option is available — otherwise pick the longest expiry offered and note the date so you can rotate before it lapses
+
+ {/* TODO(screenshot): Cal.com create API key dialog with name and expiry fields */}
+
+
+ Copy the key when it's shown. Cal.com only displays it once — if you close the dialog, you'll have to create a new key.
+
+
+
+Live-mode keys start with `cal_live_`; test-mode keys start with `cal_`. Either form is accepted.
+
+
+The API key carries the full privileges of the user who created it — there is no scope selection. Treat it like a password: do not commit it to source control or share it publicly. Sim encrypts the key at rest.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Cal.com Service Account" and click it, then click **Add to Sim** and choose **Add API key**
+
+ {/* TODO(screenshot): Integrations page with "Cal.com Service Account" in the service list */}
+
+
+ Paste the API key, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Cal.com API key dialog with the API key filled in */}
+
+
+ Click **Add API key**. Sim verifies the key by calling Cal.com's `/v2/me` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The key is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Cal.com block to your workflow. In the credential dropdown, your Cal.com service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Cal.com block in a workflow with the Cal.com service account selected as the credential */}
+
+The block calls Cal.com's API (`api.cal.com/v2`) with the key. The credential acts as the Cal.com user who created the key — bookings, event types, and schedules the workflows touch are the ones that user can see and manage.
+
+
diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx
new file mode 100644
index 00000000000..2489fc1c750
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx
@@ -0,0 +1,141 @@
+---
+title: HubSpot Private App Tokens
+description: Set up a HubSpot private app with a scoped access token to use HubSpot in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+HubSpot private apps let your workflows authenticate to HubSpot with a portal-level access token instead of a person's OAuth login. A super admin creates the app once, picks exactly the scopes it needs, and the token stays valid until it's rotated or revoked — no OAuth consent to expire, and permissions that are auditable from HubSpot's settings.
+
+This is the recommended way to use HubSpot in production workflows: the token doesn't auto-expire, its scopes are explicit, and it can be rotated on a schedule without touching anyone's personal account.
+
+## Prerequisites
+
+You need a HubSpot **super admin** to create the private app. Private apps can only be created and managed by super admins in the portal.
+
+
+The token is tied to the super admin who created the private app. If that user is later removed from the portal (or loses super admin), some API calls start failing with `USER_DOES_NOT_HAVE_PERMISSIONS` — association calls are commonly reported. Create the app from an account you expect to keep, and rotate the token if the creator ever leaves.
+
+
+## Setting Up the Private App
+
+### 1. Create the Private App
+
+
+
+ In HubSpot, navigate to **Development** in the left sidebar, then **Legacy apps**
+
+ {/* TODO(screenshot): HubSpot navigation showing Development → Legacy apps */}
+
+
+ HubSpot moved private apps under a new **Development** area and relabeled them "Legacy apps". The tokens themselves remain fully supported — only the navigation changed. If you don't see **Development**, look for **Private Apps** under **Settings** → **Integrations** in older portals.
+
+
+
+ Click **Create legacy app** in the top right, select **Private**, and give it a name (e.g. `sim-hubspot-bot`) and description
+
+
+ Open the **Scopes** tab and select the scopes your workflows need (see the list below)
+
+ {/* TODO(screenshot): private app Scopes tab with CRM scopes selected */}
+
+
+ Click **Create app**, confirm, and copy the access token from the app's **Auth** tab
+
+ {/* TODO(screenshot): private app Auth tab showing the access token with the Show/Copy controls */}
+
+
+
+### 2. Choose Scopes
+
+Private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is:
+
+**CRM objects (read/write as needed):**
+```
+crm.objects.contacts.read
+crm.objects.contacts.write
+crm.objects.companies.read
+crm.objects.companies.write
+crm.objects.deals.read
+crm.objects.deals.write
+crm.objects.line_items.read
+crm.objects.line_items.write
+crm.objects.appointments.read
+crm.objects.appointments.write
+crm.objects.owners.read
+crm.objects.users.read
+crm.objects.marketing_events.read
+crm.objects.quotes.read
+crm.objects.carts.read
+```
+
+**Tickets, emails, and lists:**
+```
+tickets
+sales-email-read
+crm.lists.read
+crm.lists.write
+```
+
+Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab, commit the change in HubSpot, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim.
+
+
+Sim's OAuth flow also requests an `oauth` scope — that one is OAuth-app-only and doesn't exist for private apps. You don't need it and can't grant it.
+
+
+### 3. Copy the Token
+
+The token looks like `pat-na1-…` (North America) or `pat-eu1-…` (EU data residency). Both work — Sim calls `api.hubapi.com`, which serves both regions.
+
+
+The access token is bearer credentials for your entire portal, limited only by its scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "HubSpot Service Account" and click it, then click **Add to Sim** and choose **Add private app token**
+
+ {/* TODO(screenshot): Integrations page with "HubSpot Service Account" in the service list */}
+
+
+ Paste the **Private app access token**, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add HubSpot private app token dialog with the private app access token filled in */}
+
+
+ Click **Add private app token**. Sim verifies the token against HubSpot's token-info endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+## Using the Service Account in Workflows
+
+Add a HubSpot block to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): HubSpot block in a workflow with the HubSpot service account selected as the credential */}
+
+The block calls `api.hubapi.com` using the private app token — exactly the same requests as the OAuth flow, so every HubSpot tool works unchanged, subject to the scopes you granted.
+
+## Rotating the Token
+
+Private app tokens don't auto-expire, but HubSpot recommends rotating them every six months and emails super admins once a token hasn't rotated in about 180 days. From the app's Auth tab you can:
+
+- **Rotate and expire now** — the old token dies immediately
+- **Rotate and expire later** — the old token keeps working for 7 days while you migrate
+
+Prefer the 7-day option: rotate in HubSpot, paste the new token into the credential in Sim, confirm your workflows run, and let the old token lapse.
+
+
diff --git a/apps/docs/content/docs/en/integrations/linear-service-account.mdx b/apps/docs/content/docs/en/integrations/linear-service-account.mdx
new file mode 100644
index 00000000000..b8aff4ba08d
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/linear-service-account.mdx
@@ -0,0 +1,86 @@
+---
+title: Linear API Keys
+description: Connect Linear to Sim with a personal API key — ideally created from a dedicated service user for production workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Linear personal API keys let your workflows authenticate without an OAuth consent flow. A key gives full parity with the Linear API — everything Sim's Linear blocks can do over OAuth works with a key.
+
+Keys are bound to the user who creates them: every action a workflow takes is attributed to that user, and the key stops working if the user is deactivated or leaves the workspace. For production workflows, create the key from a dedicated service user (e.g. `sim-bot@yourcompany.com`) rather than a personal account.
+
+## Prerequisites
+
+A Linear account that is allowed to create API keys. Workspace admins can disable API-key creation for members (**Settings** → **Administration** → **API** → **Member API keys**) — if the option is missing for you, ask an admin, or have an admin create the key (the restriction doesn't apply to admins).
+
+## Creating the API Key
+
+
+
+ Log in as the service user, open Linear **Settings**, and go to **Security & access** → **Personal API keys**
+
+ {/* TODO(screenshot): Linear settings with Security & access → API keys highlighted */}
+
+
+ Click **New API key** and give it a label (e.g. `Sim Integration`)
+
+
+ Choose the key's permissions. For full parity with Sim's Linear blocks, create it with **full access** — or, if you restrict it, grant at least **Read** and **Write** with no team restriction. A key limited to specific teams will fail on issues and projects outside those teams
+
+ {/* TODO(screenshot): Linear API key creation dialog with permission options visible */}
+
+
+ Copy the key — it starts with `lin_api_` — and store it somewhere safe.
+
+
+
+
+The API key carries the creating user's full access to your Linear workspace. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the key at rest.
+
+
+## Adding the API Key to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Linear Service Account" and click it, then click **Add to Sim** and choose **Add API key**
+
+ {/* TODO(screenshot): Integrations page with "Linear Service Account" in the service list */}
+
+
+ Paste the API key (`lin_api_...`) and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Linear API key dialog with the API key filled in */}
+
+
+ Click **Add API key**. Sim verifies the key by querying Linear's `viewer` — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+
+A key created with permission restrictions that block reading the viewer (the key's own user) is rejected at connect time. Create the key with full access, or at minimum Read + Write, so it validates and every block works.
+
+
+The key is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Linear block to your workflow. In the credential dropdown, your Linear service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Linear block in a workflow with the service account selected as the credential */}
+
+The block calls Linear's GraphQL API (`api.linear.app/graphql`) with the key. Everything the workflow does — creating issues, adding comments, updating projects — is attributed to the user who created the key.
+
+
diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json
index d972f633cfc..248715d9577 100644
--- a/apps/docs/content/docs/en/integrations/meta.json
+++ b/apps/docs/content/docs/en/integrations/meta.json
@@ -7,6 +7,7 @@
"agiloft",
"ahrefs",
"airtable",
+ "airtable-service-account",
"airweave",
"algolia",
"amplitude",
@@ -15,10 +16,12 @@
"appconfig",
"arxiv",
"asana",
+ "asana-service-account",
"ashby",
"athena",
"atlassian-service-account",
"attio",
+ "attio-service-account",
"azure_devops",
"box",
"brandfetch",
@@ -27,6 +30,7 @@
"browser_use",
"buffer",
"calcom",
+ "calcom-service-account",
"calendly",
"circleback",
"clay",
@@ -104,6 +108,7 @@
"greptile",
"hex",
"hubspot",
+ "hubspot-service-account",
"hubspot-setup",
"huggingface",
"hunter",
@@ -128,6 +133,7 @@
"leadmagic",
"lemlist",
"linear",
+ "linear-service-account",
"linkedin",
"linkup",
"linq",
@@ -146,12 +152,14 @@
"millionverifier",
"mistral_parse",
"monday",
+ "monday-service-account",
"mongodb",
"mysql",
"neo4j",
"neverbounce",
"new_relic",
"notion",
+ "notion-service-account",
"obsidian",
"okta",
"onedrive",
@@ -198,6 +206,7 @@
"sftp",
"sharepoint",
"shopify",
+ "shopify-service-account",
"similarweb",
"sixtyfour",
"slack",
@@ -219,6 +228,7 @@
"thrive",
"tinybird",
"trello",
+ "trello-service-account",
"trigger_dev",
"twilio",
"twilio_sms",
@@ -229,7 +239,9 @@
"vanta",
"vercel",
"wealthbox",
+ "wealthbox-service-account",
"webflow",
+ "webflow-service-account",
"whatsapp",
"wikipedia",
"wiza",
diff --git a/apps/docs/content/docs/en/integrations/monday-service-account.mdx b/apps/docs/content/docs/en/integrations/monday-service-account.mdx
new file mode 100644
index 00000000000..d65903d43b8
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/monday-service-account.mdx
@@ -0,0 +1,98 @@
+---
+title: monday.com API Tokens
+description: Set up a monday.com personal API token on a dedicated integration user to use monday.com in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+monday.com authenticates API calls with a personal API token — a long-lived token tied to a monday.com user. There is no separate org-level service account feature, so the standard practice is to create a dedicated integration user (e.g. `sim-bot@yourcompany.com`), give it access to the boards your workflows need, and generate the token from that seat.
+
+This is the recommended way to use monday.com in production workflows: monday.com doesn't document a scheduled expiry for personal tokens, no one person's OAuth consent is involved, and the credential survives when an employee leaves.
+
+## Prerequisites
+
+Admins and members can generate their own API token (guests and viewers cannot) — for production use, create a dedicated integration-user seat first. A token generated from a personal account stops working if that person is deactivated.
+
+
+The token's user must be **active**, **not view-only** (Viewer), and have a **confirmed email address**. Tokens from view-only or unconfirmed users are rejected by the monday.com API with a 403 error even though the token itself is valid.
+
+
+## Setting Up the Token
+
+### 1. Create a Dedicated Integration User (Recommended)
+
+
+
+ Have a monday.com admin invite a new member with a shared email like `sim-bot@yourcompany.com` and confirm the email address
+
+
+ Add the integration user to every board your workflows will read or write. The token mirrors the user's board access exactly — a board the user can't see is a board the token can't touch
+
+
+
+### 2. Generate the Personal API Token
+
+
+
+ Log in as the integration user, click your **profile picture** (top-right), and choose **Developers**
+
+ {/* TODO(screenshot): monday.com profile-picture menu with "Developers" highlighted */}
+
+
+ In the Developer Center, open the **API token** tab and click **Show** to reveal the token
+
+ {/* TODO(screenshot): Developer Center "API token" tab with the token revealed */}
+
+
+ Copy the token exactly as shown — monday.com doesn't document a fixed token format
+
+
+
+
+monday.com issues **one API token per user**. Regenerating it immediately invalidates the old token — every integration using it breaks at once, with no overlap window. Don't regenerate the token to "get a fresh copy"; reveal and copy the existing one instead.
+
+
+There is no scope picker: personal API tokens carry **all** API permission scopes automatically. What limits the token is the user's own access — board membership, item visibility, and account permissions.
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "monday.com Service Account" and click it, then click **Add to Sim** and choose **Add API token**
+
+ {/* TODO(screenshot): Integrations page with "monday.com Service Account" in the service list */}
+
+
+ Paste the API token and optionally set a display name and description
+
+ {/* TODO(screenshot): Add monday.com API token dialog with the API token filled in */}
+
+
+ Click **Add API token**. Sim verifies the token by querying monday.com's `me` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The token is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a monday.com block to your workflow. In the credential dropdown, your monday.com service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): monday.com block in a workflow with the service account selected as the credential */}
+
+The block calls monday.com's single global API endpoint (`api.monday.com/v2`) using the token — there's no per-tenant domain to configure. The token acts as the user it was created by, with that user's board access.
+
+
diff --git a/apps/docs/content/docs/en/integrations/notion-service-account.mdx b/apps/docs/content/docs/en/integrations/notion-service-account.mdx
new file mode 100644
index 00000000000..16c742e9212
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/notion-service-account.mdx
@@ -0,0 +1,114 @@
+---
+title: Notion Internal Integrations
+description: Set up a Notion internal integration with an integration secret to use Notion in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Notion internal integrations let your workflows authenticate to Notion as a workspace-owned bot user instead of through a person's OAuth login. The integration has its own secret, its own capabilities, and its own explicit list of pages and databases it can reach — all managed from the workspace's integration settings.
+
+This is the recommended way to use Notion in production workflows: the secret doesn't depend on any employee's account, its capabilities are explicit, and access is granted page by page rather than inheriting one person's entire sidebar.
+
+## Prerequisites
+
+You need a Notion **workspace owner** to create the integration. Internal integrations are a workspace-level feature — only workspace owners can access the integrations settings and create them.
+
+## Setting Up the Integration
+
+### 1. Create the Internal Integration
+
+
+
+ Open [notion.so/profile/integrations](https://www.notion.so/profile/integrations) (in Notion: **Settings** → **Connections** → **Develop or manage connections**) and create a new integration. Notion is renaming integrations to "connections" — newer workspaces see this at app.notion.com/developers/connections with a **Create a new connection** button.
+
+ {/* TODO(screenshot): Notion integrations page with the create-integration button */}
+
+
+ Give it a name (e.g. `sim-notion-bot`), pick the workspace it belongs to, and make sure you are creating an **internal** integration (the default; newer UIs list it under **Internal connections**)
+
+
+ Under **Capabilities**, enable what your workflows need:
+
+ - **Read content** — reading pages and blocks, querying and reading databases, search
+ - **Insert content** — creating pages and databases, adding database rows, appending blocks
+ - **Update content** — updating pages and blocks, deleting blocks
+ - **Comment capabilities** — creating and listing comments
+ - **User capabilities** — listing and reading workspace users (choose with or without email)
+
+ Enable only what you use — a read-only reporting workflow needs just Read content.
+
+ {/* TODO(screenshot): integration settings Capabilities section with content capabilities enabled */}
+
+
+ Save, then copy the integration's secret from its settings page — depending on your workspace's UI it is labeled **Internal Integration Secret** or **Installation access token** (under the **Configuration** tab). Secrets issued since September 2024 start with `ntn_`; older `secret_` tokens remain valid.
+
+ {/* TODO(screenshot): integration settings page showing the Internal Integration Secret with the Show/Copy controls */}
+
+
+
+
+The secret is bearer credentials for everything the integration is connected to. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the secret at rest.
+
+
+### 2. Connect the Integration to Pages and Databases
+
+This step is the one everyone misses — and without it, the integration can read **nothing**. A brand-new integration has API access but zero page access; every page or database your workflows touch must be explicitly connected.
+
+
+
+ Open a page or database your workflows need in Notion
+
+
+ Click the **•••** menu in the top-right, choose **Connections** (or **Add connections**), and select your integration
+
+ {/* TODO(screenshot): Notion page ••• menu with "Add connections" and the integration selected */}
+
+
+ Repeat for each top-level page or database. Connecting a page also grants access to all of its sub-pages.
+
+
+
+
+A valid secret with no page connections validates fine in Sim but returns `404 object_not_found` on every real call. If a Notion block reports that a page or database wasn't found — and you're sure the ID is right — the page isn't connected to the integration.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Notion Service Account" and click it, then click **Add to Sim** and choose **Add integration secret**
+
+ {/* TODO(screenshot): Integrations page with "Notion Service Account" in the service list */}
+
+
+ Paste the **Internal integration secret**, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Notion integration secret dialog with the internal integration secret filled in */}
+
+
+ Click **Add integration secret**. Sim verifies the secret by calling Notion's bot-user endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+## Using the Service Account in Workflows
+
+Add a Notion block to your workflow. In the credential dropdown, your Notion service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Notion block in a workflow with the Notion service account selected as the credential */}
+
+The block calls `api.notion.com` using the integration secret — exactly the same requests as the OAuth flow, so every Notion tool works unchanged, limited to the capabilities you enabled and the pages you connected.
+
+
diff --git a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx
new file mode 100644
index 00000000000..7111029a1fb
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx
@@ -0,0 +1,109 @@
+---
+title: Shopify Admin API Tokens
+description: Set up a Shopify custom app with an Admin API access token to use Shopify in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Shopify custom apps let your workflows authenticate to a store with an Admin API access token (`shpat_...`) — a permanent, store-bound credential that belongs to the app, not to any employee's login. You choose exactly which Admin API scopes the app gets, and the token never expires.
+
+This is the recommended way to use Shopify in production workflows: no OAuth consent to go stale, scopes are explicit and auditable, and access is revoked by uninstalling the app — without touching anyone's account.
+
+## Prerequisites
+
+You need store owner or staff permissions that allow managing apps on the store. You'll also need your store's permanent domain (`your-store.myshopify.com`) — Sim asks for it alongside the token.
+
+
+Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly.
+
+
+## Setting Up the Custom App
+
+### 1. Create the App and Grant Scopes
+
+
+
+ Open the [Dev Dashboard](https://dev.shopify.com/), create a new app (e.g. `sim-workflows`), and connect it to your store
+
+ {/* TODO(screenshot): Shopify Dev Dashboard with a new custom app created */}
+
+
+ Configure the app's **Admin API access scopes**. The set Sim's Shopify tools use is:
+
+ ```
+ write_products
+ write_orders
+ write_customers
+ write_inventory
+ read_locations
+ write_merchant_managed_fulfillment_orders
+ ```
+
+ In Shopify, `write_X` includes `read_X`, so these six cover product, collection, order, fulfillment, customer, inventory, and location operations. Grant fewer if your workflows only read — e.g. `read_products` alone is enough for product and collection lookups
+
+ {/* TODO(screenshot): Admin API access scopes picker with the six scopes selected */}
+
+
+ Install the app on your store, then obtain the `shpat_` token as described in the next section — the flow differs by app type
+
+
+
+### 2. Get the Admin API Access Token
+
+How you obtain the `shpat_` token depends on how the app was created:
+
+- **Legacy admin-created custom apps** (created from the store admin before Shopify removed that path) show the **Admin API access token** — it starts with `shpat_` — once on the app's API credentials page. Reveal it, copy it immediately, and paste it into Sim. For legacy admin-created custom apps, Shopify shows the token only once, at creation — if you lose it, you have to reinstall or recreate the app to get a new one.
+- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Complete an OAuth (authorization code grant) install with the app's client ID and secret to obtain an offline `shpat_` access token programmatically. The Dev Dashboard's client-credentials tokens are different — they expire after 24 hours and cannot be stored in Sim.
+
+{/* TODO(screenshot): legacy custom app's API credentials page with the shpat_ token revealed */}
+
+
+Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` is a custom app access token from older Partner-created custom apps — neither will work. If your Dev Dashboard app only shows a client ID and secret, that is expected: Dev Dashboard apps issue tokens via OAuth, not a UI reveal (see the setup steps above).
+
+
+### 3. Find Your Store Domain
+
+Use the permanent `.myshopify.com` domain — for example, `your-store.myshopify.com` — not your custom storefront domain. You can find it in your Shopify admin URL or under **Settings** → **Domains**.
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Shopify Service Account" and click it, then click **Add to Sim** and choose **Add admin API token**
+
+ {/* TODO(screenshot): Integrations page with "Shopify Service Account" in the service list */}
+
+
+ Paste the Admin API access token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Shopify admin API token dialog with token and store domain filled in */}
+
+
+ Click **Add admin API token**. Sim verifies the token by querying your store's `shop` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The token and store domain are encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Shopify block to your workflow. In the credential dropdown, your Shopify service account appears alongside any OAuth credentials. Select it and configure the block as you normally would — the store domain stored with the credential is used automatically.
+
+{/* TODO(screenshot): Shopify block in a workflow with the service account selected as the credential */}
+
+The block calls your store's Admin API (`your-store.myshopify.com/admin/api/...`) using the token, with exactly the scopes you granted the app.
+
+
diff --git a/apps/docs/content/docs/en/integrations/trello-service-account.mdx b/apps/docs/content/docs/en/integrations/trello-service-account.mdx
new file mode 100644
index 00000000000..57e3c8f11a0
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/trello-service-account.mdx
@@ -0,0 +1,95 @@
+---
+title: Trello API Tokens
+description: Mint a Trello API token bound to Sim's API key to use Trello in Sim workflows without OAuth
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Trello service account credentials let your workflows authenticate to Trello with a pasted API token instead of a personal OAuth connection. The token belongs to a Trello member — ideally a dedicated bot account — so workflows keep running even when individual teammates leave or revoke their own connections.
+
+
+Trello tokens are bound to the API key that authorized them. A token generated with your own Trello Power-Up key will **not** work in Sim — Trello rejects it with `401 invalid token`. The token must be minted through Trello's authorize URL using the API key of the Sim deployment you're connecting to. Follow the steps below exactly.
+
+
+## Prerequisites
+
+You need a Trello account that has access to the boards your workflows will read and write. For production workflows, create a dedicated bot Trello account (e.g. `sim-bot@yourcompany.com`), invite it to the relevant boards, and mint the token while logged in as that account — the credential then survives any individual employee leaving.
+
+## Generating the API Token
+
+Trello doesn't have a token-creation settings page. Tokens are minted by visiting an authorize URL in the browser while logged in to the Trello account that should own the token:
+
+```
+https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&name=Sim&key=
+```
+
+
+The `key` parameter must be the Trello API key of the Sim deployment:
+
+- **Sim's hosted cloud** — contact Sim support for the ready-made authorize link with the key already filled in (a future update to the connect modal may surface it in-product). Don't substitute your own key.
+- **Self-hosted Sim** — substitute the value of your deployment's `TRELLO_API_KEY` environment variable. Trello API keys are public-by-design identifiers (not secrets); yours is shown at [trello.com/power-ups/admin](https://trello.com/power-ups/admin) under your Power-Up's API key page.
+
+
+
+
+ Log in to Trello as the account that should own the token (the bot account, for production)
+
+
+ Open the authorize URL above in the browser. Trello shows a consent page describing the access being granted
+
+ {/* TODO(screenshot): Trello authorize consent page showing "Sim" requesting read/write access with never expiration */}
+
+
+ Click **Allow**. Trello displays the token on-screen — copy it
+
+ {/* TODO(screenshot): Trello token display page after clicking Allow, with the token string shown */}
+
+
+
+
+Keep `expiration=never` and `scope=read,write` in the URL exactly as shown. A token minted with a shorter expiration (`1hour`, `1day`, `30days`) will validate today and silently die later. A read-only token (`scope=read`) also validates — but every Trello write tool in your workflows (create card, update card, add comment, and so on) will then fail at runtime.
+
+
+Newer Trello tokens start with `ATTA`; older ones are 64-character hex strings. Both work — Sim doesn't enforce a format.
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Trello Service Account" and click it, then click **Add to Sim** and choose **Add API token**
+
+ {/* TODO(screenshot): Integrations page with "Trello Service Account" in the service list */}
+
+
+ Paste the API token, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Trello API token dialog with the API token filled in */}
+
+
+ Click **Add API token**. Sim verifies the token by calling Trello's `/1/members/me` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The token is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Trello block to your workflow. In the credential dropdown, your Trello service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Trello block in a workflow with the Trello service account selected as the credential */}
+
+The block calls Trello's API (`api.trello.com`) with the token, paired server-side with the deployment's API key. The credential acts as the Trello member that minted the token, with access to exactly the boards that member can see.
+
+
diff --git a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx
new file mode 100644
index 00000000000..27c927e1c11
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx
@@ -0,0 +1,80 @@
+---
+title: Wealthbox API Access Tokens
+description: Set up a Wealthbox API access token to use Wealthbox in Sim workflows without OAuth
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { FAQ } from '@/components/ui/faq'
+
+Wealthbox service account credentials let your workflows authenticate to Wealthbox with a pasted API access token instead of a personal OAuth connection. Wealthbox doesn't document an expiry for these tokens — treat them as long-lived until you revoke them — so workflows keep running without anyone renewing an OAuth consent.
+
+## Prerequisites
+
+Wealthbox trial accounts cannot use the API (calls return `402 Trial expired`). If you don't see an **API Access** section in your Wealthbox settings, contact Wealthbox support before continuing.
+
+
+Wealthbox tokens are tied to the user who creates them and carry that user's permissions. For production workflows, create the token from a dedicated service login with adequate record visibility rather than a personal account — the credential then survives any individual employee leaving.
+
+
+## Creating the API Access Token
+
+
+
+ Log in to Wealthbox, open **Settings** (the three-dot menu), and go to **API Access** (direct link: [app.crmworkspace.com/settings/access_tokens](https://app.crmworkspace.com/settings/access_tokens))
+
+ {/* TODO(screenshot): Wealthbox settings page with the API Access section open */}
+
+
+ Click **Create Access Token**, give it a label (e.g. `sim-workflows`), and save
+
+ {/* TODO(screenshot): Wealthbox Create Access Token dialog with a label filled in */}
+
+
+ Copy the token. Wealthbox doesn't document an expiry for API access tokens; you can revoke it from this same page at any time.
+
+
+
+
+The token carries the full permissions of the user who created it — there is no scope selection. Treat it like a password: do not commit it to source control or share it publicly. Sim encrypts the token at rest.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Wealthbox Service Account" and click it, then click **Add to Sim** and choose **Add access token**
+
+ {/* TODO(screenshot): Integrations page with "Wealthbox Service Account" in the service list */}
+
+
+ Paste the API access token, and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Wealthbox access token dialog with the API access token filled in */}
+
+
+ Click **Add access token**. Sim verifies the token by calling Wealthbox's `/v1/me` endpoint — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The token is encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Wealthbox block to your workflow. In the credential dropdown, your Wealthbox service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Wealthbox block in a workflow with the Wealthbox service account selected as the credential */}
+
+The block calls Wealthbox's API (`api.crmworkspace.com`) with the token. The credential acts as the Wealthbox user who created the token — contacts, tasks, and notes the workflows touch are the ones that user can see under Wealthbox's permission settings.
+
+
diff --git a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx
new file mode 100644
index 00000000000..71441847978
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx
@@ -0,0 +1,89 @@
+---
+title: Webflow Site Tokens
+description: Set up a Webflow site API token with the right scopes to use Webflow CMS in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Webflow site tokens let your workflows authenticate to a single Webflow site with a token you generate from the site's settings — no OAuth consent, no user account attached. You pick the scopes at creation, and the token is bound to exactly that site.
+
+This is the recommended way to use Webflow in production workflows: the credential belongs to the site rather than to a person, its scopes are explicit, and it can be revoked from site settings without touching anyone's account.
+
+## Prerequisites
+
+Only **site administrators** can generate site tokens. Each site allows a maximum of **5 tokens** — if you're at the cap, revoke an unused one before creating a token for Sim.
+
+## Setting Up the Site Token
+
+
+
+ Open your site's **Site settings** and go to **Apps & integrations** → **API access**
+
+ {/* TODO(screenshot): Webflow Site settings "Apps & integrations" page with the API access section highlighted */}
+
+
+ Click **Generate API token** and give it a recognizable name (e.g. `sim-workflows`)
+
+
+ Select the scopes the token needs. The minimum set for Sim's Webflow blocks is:
+
+ ```
+ Sites: Read-only
+ CMS: Read and write
+ ```
+
+ The picker offers **no access**, **read-only**, or **read and write** per category. `Sites: Read-only` (`sites:read`) is required for Sim to verify the token when you connect it. `CMS: Read and write` (`cms:read` + `cms:write`) covers Sim's Webflow tools — listing, getting, creating, updating, and deleting CMS items. Add other scopes only if you need them
+
+ {/* TODO(screenshot): Webflow token scope picker with Sites read and CMS read/write selected */}
+
+
+ Copy the token when it's shown. Webflow only displays it once — if you close the dialog, you'll have to generate a new token.
+
+
+
+
+Site tokens expire after **365 consecutive days of inactivity**. Any API call resets the clock, so a workflow that runs regularly keeps its token alive indefinitely — but a workflow that sits dormant for a year will start failing silently with authentication errors. If a workflow runs rarely, set a calendar reminder to run it (or any Webflow block) at least once a year, or check the credential periodically in Sim.
+
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Webflow Service Account" and click it, then click **Add to Sim** and choose **Add site token**
+
+ {/* TODO(screenshot): Integrations page with "Webflow Service Account" in the service list */}
+
+
+ Paste the site API token and optionally set a display name and description
+
+ {/* TODO(screenshot): Add Webflow site token dialog with the site API token filled in */}
+
+
+ Click **Add site token**. Sim verifies the token by listing the sites it can access — if it fails, you'll see a specific error explaining what went wrong. A token created without the `sites:read` scope fails this check even if its CMS scopes are correct.
+
+
+
+The token is encrypted before being stored, along with the site it grants access to.
+
+## Using the Service Account in Workflows
+
+Add a Webflow block to your workflow. In the credential dropdown, your Webflow service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Webflow block in a workflow with the service account selected as the credential */}
+
+The block calls Webflow's Data API (`api.webflow.com/v2/...`) using the token. It can only reach collections and items belonging to the token's site — pointing a block at a different site's collection fails with an access error.
+
+
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 367821cb7d0..b582fdee99f 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -7,6 +7,11 @@ 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 {
+ parseTokenServiceAccountSecretBlob,
+ type TokenServiceAccountSecretBlob,
+} from '@/lib/credentials/token-service-accounts/server'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
@@ -320,49 +325,102 @@ export async function getAtlassianServiceAccountSecret(
}
/**
- * For Atlassian service accounts, the API token IS the access token —
- * blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
- * No exchange or refresh is needed; we just decrypt and return the raw token.
+ * Result of resolving a `service_account` credential into a usable token. For
+ * Atlassian and the token-paste providers, the stored token IS the access
+ * token — no exchange or refresh is needed; Google mints a short-lived token
+ * via the JWT-bearer flow instead.
*/
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
- /** Atlassian only — the site domain. */
+ /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
domain?: string
}
/**
- * Single dispatch point for turning a `service_account` credential into an
- * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
- * `POST /api/auth/oauth/token` route go through here, so a new service-account
- * provider is one edit and an unknown provider fails loudly instead of silently
- * attempting a Google JWT.
+ * Loads and parses the decrypted secret blob for a token service-account
+ * credential (pasted long-lived provider token). Throws if the credential is
+ * missing or the blob doesn't belong to the expected provider.
*/
-export async function resolveServiceAccountToken(
+async function getTokenServiceAccountSecret(
credentialId: string,
- providerId: string | null | undefined,
- scopes?: string[],
+ providerId: string
+): Promise {
+ const [credentialRow] = await db
+ .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+
+ if (!credentialRow?.encryptedServiceAccountKey) {
+ throw new Error('Token service account secret not found')
+ }
+
+ const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
+ return parseTokenServiceAccountSecretBlob(decrypted, providerId)
+}
+
+interface ServiceAccountTokenOptions {
+ scopes?: string[]
impersonateEmail?: string
-): Promise {
- if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+}
+
+type ServiceAccountTokenResolver = (
+ credentialId: string,
+ options: ServiceAccountTokenOptions
+) => Promise
+
+/**
+ * Resolver registry for the bespoke service-account providers. Token-paste
+ * providers (registered in `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`) resolve
+ * generically: the stored token IS the access token.
+ */
+const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = {
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
- }
- if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ },
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
- }
- if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
+ },
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, { scopes, impersonateEmail }) => {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
- }
- throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
+ },
+}
+
+/**
+ * Single dispatch point for turning a `service_account` credential into an
+ * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
+ * `POST /api/auth/oauth/token` route go through here, so a new service-account
+ * provider is one registry entry and an unknown provider fails loudly instead
+ * of silently attempting a Google JWT.
+ */
+export async function resolveServiceAccountToken(
+ credentialId: string,
+ providerId: string | null | undefined,
+ scopes?: string[],
+ impersonateEmail?: string
+): Promise {
+ if (providerId && isTokenServiceAccountProviderId(providerId)) {
+ const secret = await getTokenServiceAccountSecret(credentialId, providerId)
+ return { accessToken: secret.apiToken, domain: secret.domain }
+ }
+ const resolver =
+ providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId)
+ ? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId]
+ : undefined
+ if (!resolver) {
+ throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
+ }
+ return resolver(credentialId, { scopes, impersonateEmail })
}
/**
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index db1dc91c47c..e1708f0b48b 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -27,6 +27,7 @@ import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -597,6 +598,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
}
+ if (error instanceof TokenServiceAccountValidationError) {
+ logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, {
+ code: error.code,
+ upstreamStatus: error.status,
+ ...error.logDetail,
+ })
+ return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
+ }
if (error instanceof DuplicateCredentialError) {
return NextResponse.json(
{
diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts
index 9b0b500e0db..c549654b80a 100644
--- a/apps/sim/app/api/tools/linear/projects/route.ts
+++ b/apps/sim/app/api/tools/linear/projects/route.ts
@@ -88,7 +88,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const linearClient = new LinearClient({ accessToken })
+ const linearClient = accessToken.startsWith('lin_api_')
+ ? // Personal API keys use the SDK's apiKey option (bare Authorization
+ // header), matching linearAuthorizationHeader in @/tools/linear/utils.
+ new LinearClient({ apiKey: accessToken })
+ : new LinearClient({ accessToken })
/**
* teamId may be a single ID or a comma-separated list when the basic-mode
diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts
index f71adec6b0a..a03ccaea4d6 100644
--- a/apps/sim/app/api/tools/linear/teams/route.ts
+++ b/apps/sim/app/api/tools/linear/teams/route.ts
@@ -86,7 +86,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const linearClient = new LinearClient({ accessToken })
+ const linearClient = accessToken.startsWith('lin_api_')
+ ? // Personal API keys use the SDK's apiKey option (bare Authorization
+ // header), matching linearAuthorizationHeader in @/tools/linear/utils.
+ new LinearClient({ apiKey: accessToken })
+ : new LinearClient({ accessToken })
const allTeams = await fetchAllTeams(linearClient)
const teams = allTeams.map((team: Team) => ({
id: team.id,
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 6ca5964d8c4..d7ecd79e36e 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 { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
import {
blockTypeToIconMap,
type Integration,
@@ -87,6 +88,15 @@ 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)
+ const serviceAccountConnectLabel = isSlackBot
+ ? 'Set up a custom bot'
+ : tokenDescriptor
+ ? `Add ${tokenDescriptor.connectNoun}`
+ : 'Add service account'
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
@@ -116,7 +126,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
},
{
value: CONNECT_MODE.serviceAccount,
- label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
+ label: serviceAccountConnectLabel,
icon: oauthService.serviceIcon,
},
]
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 16375108878..6c8f6722601 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,10 +14,15 @@ 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 {
+ getTokenServiceAccountDescriptor,
+ type TokenServiceAccountProviderId,
+} from '@/lib/credentials/token-service-accounts/descriptors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
+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 {
useCreateWorkspaceCredential,
@@ -32,6 +37,7 @@ export type ServiceAccountProviderId =
| typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SLACK_CUSTOM_BOT_PROVIDER_ID
+ | TokenServiceAccountProviderId
/** 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'
@@ -121,6 +127,22 @@ export function ConnectServiceAccountModal({
credentialDisplayName,
credentialDescription,
}: ConnectServiceAccountModalProps) {
+ const tokenDescriptor = getTokenServiceAccountDescriptor(serviceAccountProviderId)
+ if (tokenDescriptor) {
+ return (
+
+ )
+ }
if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
return (
void
+ workspaceId: string
+ descriptor: TokenServiceAccountDescriptor
+ serviceName: string
+ serviceIcon: ComponentType<{ className?: string }>
+ /** When set, reconnect (rotate the secret on) this credential in place. */
+ credentialId?: string
+ initialDisplayName?: string
+ initialDescription?: string
+}
+
+/**
+ * Generic connect modal for token-paste service accounts. Renders the fields
+ * declared by the provider's {@link TokenServiceAccountDescriptor} (a secret
+ * token, plus a domain for providers that need one) and submits through the
+ * same create/update credential mutations as the other service-account modals.
+ * Server-side verification failures are mapped from the route's `error.code`.
+ */
+export function TokenServiceAccountModal({
+ open,
+ onOpenChange,
+ workspaceId,
+ descriptor,
+ serviceName,
+ serviceIcon: ServiceIcon,
+ credentialId,
+ initialDisplayName,
+ initialDescription,
+}: TokenServiceAccountModalProps) {
+ const [apiToken, setApiToken] = useState('')
+ const [domain, setDomain] = 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
+ setApiToken('')
+ setDomain('')
+ setDisplayName(initialDisplayName ?? '')
+ setDescription(initialDescription ?? '')
+ setError(null)
+ }, [open, initialDisplayName, initialDescription])
+
+ const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
+ const domainField = descriptor.fields.find((field) => field.id === 'domain')
+
+ const trimmedToken = apiToken.trim()
+ const normalizedDomain = normalizeDomainInput(domain)
+ const isPending = createCredential.isPending || updateCredential.isPending
+ const isDisabled = !trimmedToken || (Boolean(domainField) && !normalizedDomain) || isPending
+
+ const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => {
+ if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined
+ return field.hintPattern.test(value) ? undefined : field.hintMessage
+ }
+
+ const handleSubmit = async () => {
+ setError(null)
+ if (isDisabled) return
+ try {
+ const secretFields = {
+ apiToken: trimmedToken,
+ ...(domainField ? { domain: normalizedDomain } : {}),
+ }
+ 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(messageForTokenAccountError(err, descriptor))
+ logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
+ }
+ }
+
+ return (
+
+ onOpenChange(false)}>
+ Add {serviceName} {descriptor.connectNoun}
+
+
+ {tokenField && (
+
+ {
+ setApiToken(value)
+ if (error) setError(null)
+ }}
+ placeholder={tokenField.placeholder}
+ name={`${descriptor.providerId}_api_token`}
+ autoComplete='new-password'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+
+ )}
+
+ {domainField && (
+ {
+ setDomain(value)
+ if (error) setError(null)
+ }}
+ placeholder={domainField.placeholder}
+ autoComplete='off'
+ required
+ error={hintFor(domainField, normalizedDomain)}
+ />
+ )}
+
+
+
+
+
+ {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/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index ecd15b68afb..22793dd27d5 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -1,10 +1,7 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
-import {
- ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- type OAuthProvider,
- SLACK_CUSTOM_BOT_PROVIDER_ID,
-} from '@/lib/oauth/types'
+import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields'
+import type { OAuthProvider } from '@/lib/oauth/types'
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
@@ -161,46 +158,14 @@ export const createCredentialBodySchema = z
}
if (data.type === 'service_account') {
- if (data.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- if (!data.apiToken) {
+ for (const field of getServiceAccountRequiredFields(data.providerId)) {
+ if (!data[field]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'apiToken is required for Atlassian service account credentials',
- path: ['apiToken'],
+ message: `${field} is required for ${data.providerId ?? 'service account'} credentials`,
+ path: [field],
})
}
- if (!data.domain) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'domain is required for Atlassian service account credentials',
- path: ['domain'],
- })
- }
- return
- }
- if (data.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
- if (!data.signingSecret) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'signingSecret is required for a custom Slack bot credential',
- path: ['signingSecret'],
- })
- }
- if (!data.botToken) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'botToken is required for a custom Slack bot credential',
- path: ['botToken'],
- })
- }
- return
- }
- if (!data.serviceAccountJson) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'serviceAccountJson is required for service account credentials',
- path: ['serviceAccountJson'],
- })
}
return
}
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index 968e0a2fb14..b2812a7e6b6 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -17,6 +17,7 @@ import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CredentialOrchestration')
@@ -116,7 +117,8 @@ export async function performUpdateCredential(
updates.encryptedServiceAccountKey = encrypted
}
- // Reconnect: rotate a Slack/Atlassian service-account secret in place. The
+ // Reconnect: rotate a service-account secret (Slack, Atlassian, or any
+ // token-paste provider) in place. The
// secret is re-verified against the provider and re-encrypted; the display
// name is preserved (the user may have renamed it).
const hasRotationSecret =
@@ -152,6 +154,14 @@ export async function performUpdateCredential(
providerErrorCode: error.code,
}
}
+ if (error instanceof TokenServiceAccountValidationError) {
+ return {
+ success: false,
+ error: error.code,
+ errorCode: 'validation',
+ providerErrorCode: error.code,
+ }
+ }
throw error
}
}
diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts
new file mode 100644
index 00000000000..ea505ddd9c4
--- /dev/null
+++ b/apps/sim/lib/credentials/service-account-fields.ts
@@ -0,0 +1,47 @@
+import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
+} from '@/lib/oauth/types'
+
+/** Every secret field a service-account credential create/reconnect can carry. */
+export type ServiceAccountFieldId =
+ | 'apiToken'
+ | 'domain'
+ | 'serviceAccountJson'
+ | 'signingSecret'
+ | 'botToken'
+
+/**
+ * Required create-body fields per service-account provider — the client-safe
+ * source of truth consumed by the `createCredentialBodySchema` superRefine.
+ * (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
+ * declared here.
+ */
+export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = {
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'],
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['apiToken', 'domain'],
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: ['signingSecret', 'botToken'],
+ ...TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS,
+}
+
+/**
+ * Legacy Google creates may omit `providerId` entirely (the original
+ * service-account flow predates multi-provider support), so an unknown or
+ * missing provider falls back to requiring the Google JSON key.
+ */
+export const FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS: readonly ServiceAccountFieldId[] = [
+ 'serviceAccountJson',
+]
+
+export function getServiceAccountRequiredFields(
+ providerId: string | null | undefined
+): readonly ServiceAccountFieldId[] {
+ return providerId && Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, providerId)
+ ? SERVICE_ACCOUNT_REQUIRED_FIELDS[providerId]
+ : FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS
+}
diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts
index 42b1be22b6c..ea33f8e2ac2 100644
--- a/apps/sim/lib/credentials/service-account-secret.test.ts
+++ b/apps/sim/lib/credentials/service-account-secret.test.ts
@@ -123,4 +123,27 @@ describe('verifyAndBuildServiceAccountSecret', () => {
expect(result.displayName).toBe('svc@proj.iam')
expect(result.encryptedServiceAccountKey).toBe(json)
})
+
+ it('accepts a legacy Google create with an empty providerId', async () => {
+ const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' })
+ const result = await verifyAndBuildServiceAccountSecret('', { serviceAccountJson: json })
+ expect(result.providerId).toBe('google-service-account')
+ })
+
+ it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => {
+ const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' })
+ await expect(
+ verifyAndBuildServiceAccountSecret('hubspot-service-acount-typo', {
+ serviceAccountJson: json,
+ })
+ ).rejects.toThrow('Unsupported service-account provider')
+ })
+
+ it('rejects prototype-chain providerIds with a validation error, not a TypeError', async () => {
+ for (const providerId of ['__proto__', 'constructor', 'toString']) {
+ await expect(
+ verifyAndBuildServiceAccountSecret(providerId, { serviceAccountJson: '{}' })
+ ).rejects.toThrow('Unsupported service-account provider')
+ }
+ })
})
diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts
index 579fae92531..a14f0d4b5ca 100644
--- a/apps/sim/lib/credentials/service-account-secret.ts
+++ b/apps/sim/lib/credentials/service-account-secret.ts
@@ -6,9 +6,19 @@ import {
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
+import {
+ getTokenServiceAccountDescriptor,
+ isTokenServiceAccountProviderId,
+ TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+} from '@/lib/credentials/token-service-accounts/descriptors'
+import {
+ getTokenServiceAccountValidator,
+ type TokenServiceAccountSecretBlob,
+} from '@/lib/credentials/token-service-accounts/server'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
SLACK_CUSTOM_BOT_SECRET_TYPE,
} from '@/lib/oauth/types'
@@ -42,84 +52,90 @@ export class ServiceAccountSecretError extends Error {
}
/**
- * Verifies a service-account secret against its provider, derives the display
- * name, and returns the encrypted blob ready to persist. Shared by credential
- * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt
- * identically. Throws {@link ServiceAccountSecretError} on missing fields or a
- * failed provider verification (callers map it to a 400).
+ * Builds an Atlassian service-account secret (scoped API token + site domain).
*/
-export async function verifyAndBuildServiceAccountSecret(
- providerId: string,
+async function buildAtlassianServiceAccountSecret(
fields: ServiceAccountSecretFields
): Promise {
- if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- const { apiToken, domain } = fields
- if (!apiToken || !domain) {
- throw new ServiceAccountSecretError(
- 'apiToken and domain are required for Atlassian service account credentials'
- )
- }
- const normalizedDomain = normalizeAtlassianDomain(domain)
- const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
- const blob = JSON.stringify({
- type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
- apiToken,
- domain: normalizedDomain,
- cloudId: validation.cloudId,
- atlassianAccountId: validation.accountId,
- })
- const { encrypted } = await encryptSecret(blob)
- return {
- providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- encryptedServiceAccountKey: encrypted,
- displayName: validation.displayName,
- auditMetadata: {
- atlassianDomain: normalizedDomain,
- atlassianCloudId: validation.cloudId,
- },
- }
+ const { apiToken, domain } = fields
+ if (!apiToken || !domain) {
+ throw new ServiceAccountSecretError(
+ 'apiToken and domain are required for Atlassian service account credentials'
+ )
}
+ const normalizedDomain = normalizeAtlassianDomain(domain)
+ const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
+ const blob = JSON.stringify({
+ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ apiToken,
+ domain: normalizedDomain,
+ cloudId: validation.cloudId,
+ atlassianAccountId: validation.accountId,
+ })
+ const { encrypted } = await encryptSecret(blob)
+ return {
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ encryptedServiceAccountKey: encrypted,
+ displayName: validation.displayName,
+ auditMetadata: {
+ atlassianDomain: normalizedDomain,
+ atlassianCloudId: validation.cloudId,
+ },
+ }
+}
- if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
- const { signingSecret, botToken } = fields
- if (!signingSecret || !botToken) {
- throw new ServiceAccountSecretError(
- 'signingSecret and botToken are required for a custom Slack bot credential'
- )
- }
- // Verify the token and derive the workspace/team identity (never trusted
- // from the client) via auth.test.
- let teamId: string
- let botUserId: string | undefined
- let teamName: string | undefined
- try {
- const auth = await fetchSlackTeamId(botToken)
- teamId = auth.teamId
- botUserId = auth.userId
- teamName = auth.teamName
- } catch (error) {
- throw new ServiceAccountSecretError(
- `Could not verify the Slack bot token: ${getErrorMessage(error)}`
- )
- }
- const blob = JSON.stringify({
- type: SLACK_CUSTOM_BOT_SECRET_TYPE,
- signingSecret,
- botToken,
- teamId,
- botUserId,
- teamName,
- })
- const { encrypted } = await encryptSecret(blob)
- return {
- providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
- encryptedServiceAccountKey: encrypted,
- displayName: teamName || 'Slack bot',
- auditMetadata: { slackTeamId: teamId },
- botUserId,
- }
+/**
+ * Builds a custom Slack bot secret. The workspace/team identity is derived via
+ * `auth.test` and never trusted from the client.
+ */
+async function buildSlackCustomBotSecret(
+ fields: ServiceAccountSecretFields
+): Promise {
+ const { signingSecret, botToken } = fields
+ if (!signingSecret || !botToken) {
+ throw new ServiceAccountSecretError(
+ 'signingSecret and botToken are required for a custom Slack bot credential'
+ )
+ }
+ let teamId: string
+ let botUserId: string | undefined
+ let teamName: string | undefined
+ try {
+ const auth = await fetchSlackTeamId(botToken)
+ teamId = auth.teamId
+ botUserId = auth.userId
+ teamName = auth.teamName
+ } catch (error) {
+ throw new ServiceAccountSecretError(
+ `Could not verify the Slack bot token: ${getErrorMessage(error)}`
+ )
+ }
+ const blob = JSON.stringify({
+ type: SLACK_CUSTOM_BOT_SECRET_TYPE,
+ signingSecret,
+ botToken,
+ teamId,
+ botUserId,
+ teamName,
+ })
+ const { encrypted } = await encryptSecret(blob)
+ return {
+ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
+ encryptedServiceAccountKey: encrypted,
+ displayName: teamName || 'Slack bot',
+ auditMetadata: { slackTeamId: teamId },
+ botUserId,
}
+}
+/**
+ * Builds a Google service-account secret from a pasted JSON key. Also the
+ * fallback for creates without a `providerId` — the original service-account
+ * flow predates multi-provider support.
+ */
+async function buildGoogleServiceAccountSecret(
+ fields: ServiceAccountSecretFields
+): Promise {
const { serviceAccountJson } = fields
if (!serviceAccountJson) {
throw new ServiceAccountSecretError(
@@ -134,9 +150,96 @@ export async function verifyAndBuildServiceAccountSecret(
}
const { encrypted } = await encryptSecret(serviceAccountJson)
return {
- providerId: 'google-service-account',
+ providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
encryptedServiceAccountKey: encrypted,
displayName: jsonParseResult.data.client_email,
auditMetadata: {},
}
}
+
+/**
+ * Builds a token-paste service-account secret for any provider registered in
+ * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the
+ * provider's registered validator and persists it (plus any normalized domain
+ * and non-secret metadata) in the encrypted blob.
+ */
+async function buildTokenServiceAccountSecret(
+ providerId: string,
+ fields: ServiceAccountSecretFields
+): Promise {
+ const descriptor = getTokenServiceAccountDescriptor(providerId)
+ const validator = getTokenServiceAccountValidator(providerId)
+ if (!descriptor || !validator) {
+ throw new ServiceAccountSecretError(
+ `No validator registered for service-account provider ${providerId}`
+ )
+ }
+ const apiToken = fields.apiToken?.trim()
+ const domain = fields.domain?.trim()
+ const requiresDomain = descriptor.fields.some((field) => field.id === 'domain')
+ if (!apiToken || (requiresDomain && !domain)) {
+ const required = descriptor.fields.map((field) => field.id).join(' and ')
+ throw new ServiceAccountSecretError(
+ `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials`
+ )
+ }
+ const validation = await validator({ apiToken, domain })
+ const blob: TokenServiceAccountSecretBlob = {
+ type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+ providerId,
+ apiToken,
+ ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}),
+ ...(validation.storedMetadata ? { metadata: validation.storedMetadata } : {}),
+ }
+ const { encrypted } = await encryptSecret(JSON.stringify(blob))
+ return {
+ providerId,
+ encryptedServiceAccountKey: encrypted,
+ displayName: validation.displayName,
+ auditMetadata: validation.auditMetadata,
+ }
+}
+
+type ServiceAccountSecretBuilder = (
+ fields: ServiceAccountSecretFields
+) => Promise
+
+/**
+ * Builder registry for the bespoke service-account providers. Token-paste
+ * providers resolve through `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` instead of
+ * individual entries here.
+ */
+const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = {
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: buildAtlassianServiceAccountSecret,
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: buildSlackCustomBotSecret,
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: buildGoogleServiceAccountSecret,
+}
+
+/**
+ * Verifies a service-account secret against its provider, derives the display
+ * name, and returns the encrypted blob ready to persist. Shared by credential
+ * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt
+ * identically. Dispatches through the builder registry (bespoke providers)
+ * or the token-paste registry; an unknown/missing provider falls back to the
+ * Google JSON-key builder for legacy creates. Throws
+ * {@link ServiceAccountSecretError} on missing fields or a failed provider
+ * verification (callers map it to a 400).
+ */
+export async function verifyAndBuildServiceAccountSecret(
+ providerId: string,
+ fields: ServiceAccountSecretFields
+): Promise {
+ const builder = Object.hasOwn(SERVICE_ACCOUNT_SECRET_BUILDERS, providerId)
+ ? SERVICE_ACCOUNT_SECRET_BUILDERS[providerId]
+ : undefined
+ if (builder) return builder(fields)
+ if (isTokenServiceAccountProviderId(providerId)) {
+ return buildTokenServiceAccountSecret(providerId, fields)
+ }
+ if (!providerId) {
+ // Legacy Google creates omit providerId entirely (the original flow
+ // predates multi-provider support).
+ return buildGoogleServiceAccountSecret(fields)
+ }
+ throw new ServiceAccountSecretError(`Unsupported service-account provider: ${providerId}`)
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts
new file mode 100644
index 00000000000..eac00def403
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts
@@ -0,0 +1,334 @@
+/**
+ * Client-safe descriptors for token-paste service-account providers.
+ *
+ * A token service account is a `service_account`-type credential where a
+ * workspace admin pastes a long-lived provider token (private-app token, PAT,
+ * API key, …) instead of running an OAuth flow — mirroring the Atlassian
+ * service-account pattern: the token is verified once server-side, encrypted,
+ * and returned as the access token at execution time with no exchange or
+ * refresh. This module holds only UI/contract metadata (field lists, labels,
+ * docs links); server-side verification lives in
+ * `@/lib/credentials/token-service-accounts/server`.
+ */
+
+/** Discriminator stored inside every encrypted token service-account secret blob. */
+export const TOKEN_SERVICE_ACCOUNT_SECRET_TYPE = 'token_service_account' as const
+
+/** Contract field ids a token service-account modal may collect. */
+export type TokenServiceAccountFieldId = 'apiToken' | 'domain'
+
+export interface TokenServiceAccountField {
+ id: TokenServiceAccountFieldId
+ 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
+}
+
+export interface TokenServiceAccountDescriptor {
+ /** Stable credential `providerId` (`-service-account`). */
+ providerId: string
+ /** Human service label used in modal copy and error messages (e.g. "HubSpot"). */
+ serviceLabel: string
+ /** Vendor noun for the pasted secret (e.g. "private app access token"). */
+ tokenNoun: string
+ /**
+ * Short vendor-accurate noun for connect-surface labels ("Add {connectNoun}").
+ * These providers don't have literal "service accounts" — the UI uses the
+ * vendor's own vocabulary for the credential.
+ */
+ connectNoun: string
+ fields: TokenServiceAccountField[]
+ /** Sim setup guide, docked bottom-left of the connect modal. */
+ docsUrl: string
+ /** Optional one-line caveat rendered under the token field. */
+ helpText?: string
+}
+
+export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const
+export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const
+export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const
+export const ASANA_SERVICE_ACCOUNT_PROVIDER_ID = 'asana-service-account' as const
+export const ATTIO_SERVICE_ACCOUNT_PROVIDER_ID = 'attio-service-account' as const
+export const LINEAR_SERVICE_ACCOUNT_PROVIDER_ID = 'linear-service-account' as const
+export const MONDAY_SERVICE_ACCOUNT_PROVIDER_ID = 'monday-service-account' as const
+export const SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID = 'shopify-service-account' as const
+export const WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'webflow-service-account' as const
+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
+
+const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i
+
+export type TokenServiceAccountProviderId =
+ | typeof HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof NOTION_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof ASANA_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof ATTIO_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof LINEAR_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof MONDAY_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof TRELLO_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof CALCOM_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID
+
+export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
+ TokenServiceAccountProviderId,
+ TokenServiceAccountDescriptor
+> = {
+ [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'HubSpot',
+ tokenNoun: 'private app access token',
+ connectNoun: 'private app token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Private app access token',
+ placeholder: 'pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ secret: true,
+ hintPattern: /^pat-/i,
+ hintMessage: 'HubSpot private app tokens usually start with pat-.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/hubspot-service-account',
+ helpText:
+ 'Tokens are tied to the super admin who created the private app; if that user is removed from the portal, some calls may start failing.',
+ },
+ [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Airtable',
+ tokenNoun: 'personal access token',
+ connectNoun: 'personal access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Personal access token',
+ placeholder: 'pat...',
+ secret: true,
+ hintPattern: /^pat/i,
+ hintMessage: 'Airtable personal access tokens usually start with pat.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/airtable-service-account',
+ helpText:
+ 'Enterprise Scale service-account tokens work here too — they use the same format as personal access tokens.',
+ },
+ [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Notion',
+ tokenNoun: 'internal integration secret',
+ connectNoun: 'integration secret',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Internal integration secret',
+ placeholder: 'ntn_...',
+ secret: true,
+ hintPattern: /^(ntn_|secret_)/,
+ hintMessage: 'Notion integration secrets usually start with ntn_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/notion-service-account',
+ helpText:
+ 'Newer Notion UIs label the secret "installation access token". Remember to connect the integration to the pages and databases it should access — a valid secret with no page connections can read nothing.',
+ },
+ [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: ASANA_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Asana',
+ tokenNoun: 'access token',
+ connectNoun: 'access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Access token',
+ placeholder: 'Paste a service account token or personal access token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/asana-service-account',
+ helpText:
+ 'Enterprise service account tokens and personal access tokens both work — they use the same format.',
+ },
+ [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: ATTIO_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Attio',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'Paste workspace API key',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/attio-service-account',
+ helpText:
+ 'Check the scopes granted to the key in Attio — tools whose scopes are missing will fail at run time.',
+ },
+ [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Linear',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'lin_api_...',
+ secret: true,
+ hintPattern: /^lin_api_/,
+ hintMessage: 'Linear personal API keys start with lin_api_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/linear-service-account',
+ },
+ [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: MONDAY_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'monday.com',
+ 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/monday-service-account',
+ helpText:
+ 'monday.com issues one API token per user — regenerating it in monday breaks every integration using the old token.',
+ },
+ [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Shopify',
+ tokenNoun: 'Admin API access token',
+ connectNoun: 'admin API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Admin API access token',
+ placeholder: 'shpat_...',
+ secret: true,
+ hintPattern: /^shpat_/,
+ hintMessage: 'Shopify Admin API access tokens usually start with shpat_.',
+ },
+ {
+ id: 'domain',
+ label: 'Store domain',
+ placeholder: 'your-store.myshopify.com',
+ secret: false,
+ hintPattern: SHOPIFY_DOMAIN_HINT_REGEX,
+ hintMessage: 'Shopify store domains look like your-store.myshopify.com.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account',
+ helpText:
+ 'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.',
+ },
+ [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Webflow',
+ tokenNoun: 'site API token',
+ connectNoun: 'site token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Site API token',
+ placeholder: 'Paste site API token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account',
+ helpText:
+ 'Create the token with at least the sites:read and CMS read/write scopes. Site tokens expire after 365 days without API activity, and each token grants access to a single site.',
+ },
+ [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Trello',
+ tokenNoun: 'API token',
+ connectNoun: 'API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API token',
+ placeholder: 'ATTA...',
+ secret: true,
+ hintPattern: /^ATTA/,
+ hintMessage: 'Trello API tokens usually start with ATTA.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/trello-service-account',
+ helpText:
+ "Generate the token with the setup guide's authorize link (expiration=never) so it works with Sim and doesn't expire.",
+ },
+ [CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Cal.com',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'cal_live_...',
+ secret: true,
+ hintPattern: /^cal_/,
+ hintMessage: 'Cal.com API keys usually start with cal_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/calcom-service-account',
+ helpText: 'Choose a non-expiring key (or note the expiry date) when creating it in Cal.com.',
+ },
+ [WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Wealthbox',
+ tokenNoun: 'API access token',
+ connectNoun: 'access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API access token',
+ placeholder: 'Paste API access token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/wealthbox-service-account',
+ helpText:
+ 'Trial accounts cannot use the Wealthbox API; contact Wealthbox support if API Access is missing from your Settings.',
+ },
+}
+
+/**
+ * Required contract fields per token service-account provider, consumed by the
+ * `createCredentialBodySchema` superRefine so validation errors name the exact
+ * missing field. Derived from each descriptor's field list.
+ */
+export const TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS: Record =
+ Object.fromEntries(
+ Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).map((descriptor) => [
+ descriptor.providerId,
+ descriptor.fields.map((field) => field.id),
+ ])
+ )
+
+export function isTokenServiceAccountProviderId(
+ value: string | null | undefined
+): value is TokenServiceAccountProviderId {
+ return Boolean(value && Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, value))
+}
+
+export function getTokenServiceAccountDescriptor(
+ providerId: string | null | undefined
+): TokenServiceAccountDescriptor | undefined {
+ return isTokenServiceAccountProviderId(providerId)
+ ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId]
+ : undefined
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts
new file mode 100644
index 00000000000..2292b102dad
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts
@@ -0,0 +1,137 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+
+const mockFetch = vi.fn()
+
+const PROVIDER_URL = 'https://api.example-provider.com/v1/self'
+
+async function expectValidationError(
+ promise: Promise
+): Promise {
+ const error = await promise.then(
+ () => {
+ throw new Error('expected a TokenServiceAccountValidationError to be thrown')
+ },
+ (e: unknown) => e
+ )
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ return error as TokenServiceAccountValidationError
+}
+
+describe('token service-account error helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('fetchProvider', () => {
+ it('maps a rejected fetch to provider_unavailable 502', async () => {
+ mockFetch.mockRejectedValue(new TypeError('fetch failed'))
+
+ const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ expect(error.logDetail).toEqual({ step: 'self', reason: 'network error reaching provider' })
+ })
+
+ it('never includes the URL in logDetail', async () => {
+ mockFetch.mockRejectedValue(new Error(`ECONNREFUSED ${PROVIDER_URL}`))
+
+ const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self'))
+
+ expect(JSON.stringify(error.logDetail)).not.toContain(PROVIDER_URL)
+ expect(JSON.stringify(error.logDetail)).not.toContain('example-provider')
+ })
+
+ it('returns the response untouched when fetch resolves', async () => {
+ const res = new Response('ok', { status: 200 })
+ mockFetch.mockResolvedValue(res)
+
+ await expect(fetchProvider(PROVIDER_URL, {}, 'self')).resolves.toBe(res)
+ })
+ })
+
+ describe('parseProviderJson', () => {
+ it('maps a non-JSON body to provider_unavailable 502', async () => {
+ const res = new Response('502 Bad Gateway', { status: 200 })
+
+ const error = await expectValidationError(parseProviderJson(res, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ expect(error.logDetail).toEqual({
+ step: 'self',
+ reason: 'provider returned a non-JSON response body',
+ })
+ })
+
+ it('returns the parsed body for valid JSON', async () => {
+ const res = new Response(JSON.stringify({ id: 'acct-1' }), { status: 200 })
+
+ await expect(parseProviderJson(res, 'self')).resolves.toEqual({ id: 'acct-1' })
+ })
+ })
+
+ describe('throwForProviderResponse', () => {
+ it.each([401, 403])(
+ 'maps %i to invalid_credentials with the response status',
+ async (status) => {
+ const res = new Response('denied', { status })
+
+ const error = await expectValidationError(throwForProviderResponse(res, 'self'))
+
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(status)
+ }
+ )
+
+ it.each([429, 500, 503])(
+ 'maps %i to provider_unavailable with the response status',
+ async (status) => {
+ const res = new Response('provider trouble', { status })
+
+ const error = await expectValidationError(throwForProviderResponse(res, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(status)
+ }
+ )
+
+ it('returns without throwing on a 2xx response', async () => {
+ const res = new Response('ok', { status: 200 })
+
+ await expect(throwForProviderResponse(res, 'self')).resolves.toBeUndefined()
+ })
+ })
+
+ describe('readProviderErrorSnippet', () => {
+ it('truncates the body to 500 characters', async () => {
+ const res = new Response('x'.repeat(2000), { status: 500 })
+
+ const snippet = await readProviderErrorSnippet(res)
+
+ expect(snippet).toBe(`${'x'.repeat(500)}...`)
+ })
+
+ it('never throws on an unreadable body', async () => {
+ const res = new Response('gone', { status: 500 })
+ vi.spyOn(res, 'text').mockRejectedValue(new Error('body stream already read'))
+
+ await expect(readProviderErrorSnippet(res)).resolves.toBe('')
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts
new file mode 100644
index 00000000000..1c8e1114531
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts
@@ -0,0 +1,102 @@
+import { truncate } from '@sim/utils/string'
+
+/**
+ * Discrete validation failure codes returned to the client for token
+ * service-account credentials. The UI maps each code to a human message; raw
+ * provider response bodies stay in server logs.
+ */
+export type TokenServiceAccountValidationCode =
+ | 'invalid_credentials'
+ | 'site_not_found'
+ | 'provider_unavailable'
+
+export class TokenServiceAccountValidationError extends Error {
+ constructor(
+ public readonly code: TokenServiceAccountValidationCode,
+ public readonly status: number,
+ public readonly logDetail?: Record
+ ) {
+ super(code)
+ this.name = 'TokenServiceAccountValidationError'
+ }
+}
+
+const ERROR_SNIPPET_MAX_LENGTH = 500
+
+/**
+ * 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.
+ */
+const PROVIDER_FETCH_TIMEOUT_MS = 10_000
+
+export async function fetchProvider(
+ url: string,
+ init: RequestInit,
+ step: string
+): Promise {
+ try {
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) })
+ } catch {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step,
+ reason: 'network error reaching provider',
+ })
+ }
+}
+
+/**
+ * Parses a provider response body as JSON, mapping malformed bodies (proxy
+ * error pages, truncated responses) to `provider_unavailable` instead of an
+ * unhandled SyntaxError that would surface as a generic 500.
+ */
+export async function parseProviderJson(res: Response, step: string): Promise {
+ try {
+ return (await res.json()) as T
+ } catch {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step,
+ reason: 'provider returned a non-JSON response body',
+ })
+ }
+}
+
+/**
+ * Reads a bounded snippet of a provider error body for server logs. Never
+ * throws — an unreadable body logs as an empty string.
+ */
+export async function readProviderErrorSnippet(res: Response): Promise {
+ try {
+ return truncate(await res.text(), ERROR_SNIPPET_MAX_LENGTH)
+ } catch {
+ return ''
+ }
+}
+
+/**
+ * Maps a failed provider verification response to the standard error split:
+ * 401/403 mean the pasted token was rejected (`invalid_credentials`), anything
+ * else non-2xx means the provider couldn't be reached or misbehaved
+ * (`provider_unavailable`) — never blame the token for a provider outage.
+ */
+export async function throwForProviderResponse(
+ res: Response,
+ step: string,
+ context: Record = {}
+): Promise {
+ if (res.ok) return
+ const body = await readProviderErrorSnippet(res)
+ if (res.status === 401 || res.status === 403) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step,
+ body,
+ ...context,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step,
+ body,
+ ...context,
+ })
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts
new file mode 100644
index 00000000000..c0a2ed16d1d
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/server.ts
@@ -0,0 +1,113 @@
+import {
+ AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ ASANA_SERVICE_ACCOUNT_PROVIDER_ID,
+ ATTIO_SERVICE_ACCOUNT_PROVIDER_ID,
+ CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
+ HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
+ isTokenServiceAccountProviderId,
+ LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
+ MONDAY_SERVICE_ACCOUNT_PROVIDER_ID,
+ NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
+ SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
+ TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+ type TokenServiceAccountProviderId,
+ TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
+ WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
+} from '@/lib/credentials/token-service-accounts/descriptors'
+import { validateAirtableServiceAccount } from '@/lib/credentials/token-service-accounts/validators/airtable'
+import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana'
+import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio'
+import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom'
+import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
+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 { 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'
+import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow'
+
+/** Raw fields a token service-account validator receives (already trimmed). */
+export interface TokenServiceAccountFields {
+ apiToken: string
+ domain?: string
+}
+
+/** Result of a successful provider verification. */
+export interface TokenServiceAccountValidationResult {
+ /** Default display name when the user didn't provide one. */
+ displayName: string
+ /** Non-secret identifiers recorded in the audit log (e.g. portal/workspace id). */
+ auditMetadata: Record
+ /**
+ * Non-secret metadata persisted inside the encrypted blob alongside the
+ * token (e.g. normalized store domain, portal id) for later debugging.
+ */
+ storedMetadata?: Record
+ /** Normalized domain to persist instead of the raw user input (when collected). */
+ normalizedDomain?: string
+}
+
+export type TokenServiceAccountValidator = (
+ fields: TokenServiceAccountFields
+) => Promise
+
+/**
+ * Server-side verification registry for token service-account providers. Keys
+ * must stay in lockstep with `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` — a
+ * descriptor without a validator fails loudly at create time.
+ */
+const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
+ TokenServiceAccountProviderId,
+ TokenServiceAccountValidator
+> = {
+ [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: validateHubspotServiceAccount,
+ [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: validateAirtableServiceAccount,
+ [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: validateNotionServiceAccount,
+ [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAsanaServiceAccount,
+ [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: validateAttioServiceAccount,
+ [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: validateLinearServiceAccount,
+ [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateMondayServiceAccount,
+ [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: validateShopifyServiceAccount,
+ [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateWebflowServiceAccount,
+ [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: validateTrelloServiceAccount,
+ [CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalcomServiceAccount,
+ [WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: validateWealthboxServiceAccount,
+}
+
+export function getTokenServiceAccountValidator(
+ providerId: string
+): TokenServiceAccountValidator | undefined {
+ return isTokenServiceAccountProviderId(providerId)
+ ? TOKEN_SERVICE_ACCOUNT_VALIDATORS[providerId]
+ : undefined
+}
+
+/**
+ * Shape of the decrypted secret blob persisted for token service accounts.
+ * `providerId` is stored inside the blob so a mismatched credential row fails
+ * loudly at resolution time instead of returning another provider's token.
+ */
+export interface TokenServiceAccountSecretBlob {
+ type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE
+ providerId: string
+ apiToken: string
+ domain?: string
+ metadata?: Record
+}
+
+export function parseTokenServiceAccountSecretBlob(
+ decrypted: string,
+ expectedProviderId: string
+): TokenServiceAccountSecretBlob {
+ const parsed = JSON.parse(decrypted) as TokenServiceAccountSecretBlob
+ if (
+ parsed.type !== TOKEN_SERVICE_ACCOUNT_SECRET_TYPE ||
+ parsed.providerId !== expectedProviderId ||
+ !parsed.apiToken
+ ) {
+ throw new Error('Stored token service-account secret is malformed')
+ }
+ return parsed
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts
new file mode 100644
index 00000000000..b1f4797b509
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAirtableServiceAccount } from '@/lib/credentials/token-service-accounts/validators/airtable'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateAirtableServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns email display name and metadata on success with email', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ id: 'usrABC123',
+ email: 'svc@example.com',
+ scopes: ['data.records:read'],
+ })
+ )
+
+ const result = await validateAirtableServiceAccount({ apiToken: 'pat123.secret' })
+
+ expect(result).toEqual({
+ displayName: 'svc@example.com',
+ auditMetadata: { airtableUserId: 'usrABC123' },
+ storedMetadata: { userId: 'usrABC123', scopes: 'data.records:read' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.airtable.com/v0/meta/whoami', {
+ headers: {
+ Authorization: 'Bearer pat123.secret',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to id display name when email is absent', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 'usrXYZ789' }))
+
+ const result = await validateAirtableServiceAccount({ apiToken: 'pat456.secret' })
+
+ expect(result.displayName).toBe('Airtable user usrXYZ789')
+ expect(result.auditMetadata).toEqual({ airtableUserId: 'usrXYZ789' })
+ expect(result.storedMetadata).toEqual({ userId: 'usrXYZ789' })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 401 }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws provider_unavailable when a 200 body is missing id', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { email: 'svc@example.com' }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'pat' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'pat' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts
new file mode 100644
index 00000000000..ccab65691eb
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts
@@ -0,0 +1,60 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Validates an Airtable personal access token (including Enterprise
+ * service-account PATs) by calling the `whoami` meta endpoint, which requires
+ * no scopes. Airtable documents no body shape for 401 responses, so failures
+ * key off the status code alone: 401/403 mean the token was rejected, any
+ * other non-2xx means Airtable is unavailable.
+ *
+ * `email` is present only when the token has the `user.email:read` scope, and
+ * `scopes` is returned only for OAuth access tokens (never PATs) — both are
+ * optional and their absence is not a failure.
+ */
+export async function validateAirtableServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const whoamiRes = await fetchProvider(
+ 'https://api.airtable.com/v0/meta/whoami',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'whoami'
+ )
+ await throwForProviderResponse(whoamiRes, 'whoami')
+
+ const whoami = await parseProviderJson<{
+ id?: string
+ email?: string
+ scopes?: string[]
+ }>(whoamiRes, 'whoami')
+ if (!whoami.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'whoami',
+ reason: 'missing id in response',
+ })
+ }
+
+ const storedMetadata: Record = { userId: whoami.id }
+ if (whoami.scopes) {
+ storedMetadata.scopes = whoami.scopes.join(' ')
+ }
+
+ return {
+ displayName: whoami.email ?? `Airtable user ${whoami.id}`,
+ auditMetadata: { airtableUserId: whoami.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts
new file mode 100644
index 00000000000..6e950a55002
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateAsanaServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ mockFetch.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns display name and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ data: { gid: '12345', name: 'Sim Integration', email: 'bot@example.com' },
+ })
+ )
+
+ const result = await validateAsanaServiceAccount({ apiToken: 'token-1' })
+
+ expect(result).toEqual({
+ displayName: 'Sim Integration',
+ auditMetadata: { asanaUserGid: '12345' },
+ storedMetadata: { userGid: '12345', email: 'bot@example.com' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email',
+ {
+ headers: {
+ Authorization: 'Bearer token-1',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ }
+ )
+ })
+
+ it('falls back to email then gid when name is missing', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, { data: { gid: '999', email: 'fallback@example.com' } })
+ )
+
+ const withEmail = await validateAsanaServiceAccount({ apiToken: 'token-2' })
+ expect(withEmail.displayName).toBe('fallback@example.com')
+
+ mockFetch.mockResolvedValue(jsonResponse(200, { data: { gid: '999' } }))
+
+ const gidOnly = await validateAsanaServiceAccount({ apiToken: 'token-2' })
+ expect(gidOnly.displayName).toBe('Asana user 999')
+ expect(gidOnly.storedMetadata).toEqual({ userGid: '999' })
+ })
+
+ it('maps 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { errors: [{ message: 'Not Authorized' }] }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'bad-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('maps 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { errors: [{ message: 'Server error' }] }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-3' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('maps a non-JSON 200 response to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(new Response('gateway', { status: 200 }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-5' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('maps a 200 response missing data.gid to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { data: { name: 'No Gid' } }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-4' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts
new file mode 100644
index 00000000000..c138258ee35
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts
@@ -0,0 +1,62 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const ASANA_ME_URL = 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email'
+
+interface AsanaMeResponse {
+ data?: {
+ gid?: string
+ name?: string
+ email?: string
+ }
+}
+
+/**
+ * Validates an Asana service-account (or personal access) token by calling
+ * `GET /users/me`. Asana tokens are documented as opaque, so no format checks
+ * are performed — the live API call is the only gate. 401/403 mean the token
+ * was rejected; any other non-2xx means Asana is unavailable.
+ */
+export async function validateAsanaServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ ASANA_ME_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'users_me'
+ )
+ await throwForProviderResponse(res, 'users_me')
+
+ const body = await parseProviderJson(res, 'users_me')
+ const gid = body.data?.gid
+ if (!gid) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'users_me',
+ reason: 'missing data.gid in response',
+ })
+ }
+
+ const name = body.data?.name
+ const email = body.data?.email
+ const storedMetadata: Record = { userGid: gid }
+ if (email) storedMetadata.email = email
+
+ return {
+ displayName: name || email || `Asana user ${gid}`,
+ auditMetadata: { asanaUserGid: gid },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts
new file mode 100644
index 00000000000..7c193f063e5
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts
@@ -0,0 +1,125 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+async function expectValidationError(
+ promise: Promise,
+ code: string
+): Promise {
+ const error = await promise.then(
+ () => {
+ throw new Error('expected validation to throw')
+ },
+ (e: unknown) => e
+ )
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect((error as TokenServiceAccountValidationError).code).toBe(code)
+ return error as TokenServiceAccountValidationError
+}
+
+describe('validateAttioServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns workspace info on an active token', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse({
+ active: true,
+ workspace_id: 'ws-123',
+ workspace_name: 'Acme CRM',
+ workspace_slug: 'acme-crm',
+ })
+ )
+
+ const result = await validateAttioServiceAccount({ apiToken: 'attio-token' })
+
+ expect(mockFetch).toHaveBeenCalledWith('https://api.attio.com/v2/self', {
+ headers: {
+ Authorization: 'Bearer attio-token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ expect(result).toEqual({
+ displayName: 'Acme CRM',
+ auditMetadata: { attioWorkspaceId: 'ws-123' },
+ storedMetadata: { workspaceId: 'ws-123', workspaceSlug: 'acme-crm' },
+ })
+ })
+
+ it('maps a 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ error: 'unauthorized' }, 401))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'bad-token' }),
+ 'invalid_credentials'
+ )
+ })
+
+ it('maps a 400 (malformed token not recognised) to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse({ status_code: 400, type: 'invalid_request_error' }, 400)
+ )
+
+ const error = await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'not-a-real-key' }),
+ 'invalid_credentials'
+ )
+ expect(error.status).toBe(400)
+ })
+
+ it('maps a 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ error: 'boom' }, 500))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ })
+
+ it('maps a malformed 200 (missing workspace fields) to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ unexpected: 'shape' }))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ })
+
+ it('maps a JSON-valid but non-object 200 body (null) to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(null))
+
+ const error = await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ expect(error.logDetail).toEqual({ step: 'self', reason: 'non-object response body' })
+ })
+
+ it('maps a revoked token (active === false) to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ active: false }))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'revoked-token' }),
+ 'invalid_credentials'
+ )
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts
new file mode 100644
index 00000000000..1969439c4ac
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.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 ATTIO_SELF_URL = 'https://api.attio.com/v2/self'
+
+interface AttioSelfResponse {
+ active?: boolean
+ workspace_id?: string
+ workspace_name?: string
+ workspace_slug?: string
+}
+
+/**
+ * Validates an Attio workspace access token against the identify endpoint
+ * (`GET /v2/self`). Attio returns a minimal `{ active: false }` body for a
+ * revoked/deleted token, so a 2xx alone is never trusted — the body must
+ * assert `active === true` and carry the workspace identifiers. Attio replies
+ * HTTP 400 ("Token was not recognised") for a malformed pasted key; since the
+ * request carries no user input besides the bearer token, a 400 here is
+ * treated as `invalid_credentials` rather than a provider fault.
+ */
+export async function validateAttioServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ ATTIO_SELF_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'self'
+ )
+ if (res.status === 400) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'self',
+ reason: 'token was not recognised (HTTP 400)',
+ })
+ }
+ await throwForProviderResponse(res, 'self')
+
+ const self = await parseProviderJson(res, 'self')
+ if (typeof self !== 'object' || self === null) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'self',
+ reason: 'non-object response body',
+ })
+ }
+
+ if (self.active === false) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'self',
+ reason: 'token is revoked (active === false)',
+ })
+ }
+ if (self.active !== true || !self.workspace_id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'self',
+ reason: 'response missing active flag or workspace_id',
+ })
+ }
+
+ const storedMetadata: Record = { workspaceId: self.workspace_id }
+ if (self.workspace_slug) {
+ storedMetadata.workspaceSlug = self.workspace_slug
+ }
+
+ return {
+ displayName: self.workspace_name || 'Attio workspace',
+ auditMetadata: { attioWorkspaceId: self.workspace_id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts
new file mode 100644
index 00000000000..cbd22a5b1a2
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts
@@ -0,0 +1,95 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateCalcomServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ mockFetch.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns display name and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ status: 'success',
+ data: { id: 42, username: 'sim-bot', email: 'bot@example.com' },
+ })
+ )
+
+ const result = await validateCalcomServiceAccount({ apiToken: 'cal_live_token' })
+
+ expect(result).toEqual({
+ displayName: 'sim-bot',
+ auditMetadata: { calcomUserId: '42' },
+ storedMetadata: { userId: '42', email: 'bot@example.com' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.cal.com/v2/me', {
+ headers: {
+ Authorization: 'Bearer cal_live_token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('maps 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { status: 'error' }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('maps 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { message: 'Server error' }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('maps a 200 response with a non-JSON body to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(
+ new Response('gateway timeout', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('maps a 200 response with a non-success envelope to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { status: 'error', data: { id: 42 } }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts
new file mode 100644
index 00000000000..c536bb43b1e
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts
@@ -0,0 +1,65 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const CALCOM_ME_URL = 'https://api.cal.com/v2/me'
+
+interface CalcomMeResponse {
+ status?: string
+ data?: {
+ id?: number
+ username?: string
+ email?: string
+ }
+}
+
+/**
+ * Validates a Cal.com API key by calling `GET /v2/me`. Cal.com accepts an API
+ * key in the same `Authorization: Bearer` slot the tools use for OAuth tokens,
+ * and this endpoint needs no `cal-api-version` header (unlike the per-resource
+ * tool endpoints). 401/403 mean the key was rejected; any other non-2xx — or a
+ * 200 without the standard `{ status: 'success', data }` envelope — means
+ * Cal.com is unavailable.
+ */
+export async function validateCalcomServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ CALCOM_ME_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'me'
+ )
+ await throwForProviderResponse(res, 'me')
+
+ const body = await parseProviderJson(res, 'me')
+ if (body.status !== 'success' || body.data?.id === undefined) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ reason: 'unexpected response envelope from /v2/me',
+ })
+ }
+
+ const userId = String(body.data.id)
+ const username = body.data.username
+ const email = body.data.email
+ const storedMetadata: Record = { userId }
+ if (email) storedMetadata.email = email
+
+ return {
+ displayName: username || email || 'Cal.com account',
+ auditMetadata: { calcomUserId: userId },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts
new file mode 100644
index 00000000000..1259b11ac0a
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts
@@ -0,0 +1,174 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
+
+const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info'
+const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details'
+
+const FIELDS = { apiToken: 'pat-na1-aaaa-bbbb' }
+
+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 expectPrimaryCall(): void {
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(TOKEN_INFO_URL)
+ expect(init.method).toBe('POST')
+ expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb')
+ expect(init.headers['Content-Type']).toBe('application/json')
+ expect(JSON.parse(init.body)).toEqual({ tokenKey: 'pat-na1-aaaa-bbbb' })
+}
+
+function expectFallbackCall(): void {
+ const [url, init] = mockFetch.mock.calls[1]
+ expect(url).toBe(ACCOUNT_INFO_URL)
+ expect(init.method).toBeUndefined()
+ expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb')
+ expect(init.headers.Accept).toBe('application/json')
+}
+
+describe('validateHubspotServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on primary access-token-info success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ userId: 111,
+ hubId: 12345,
+ appId: 222,
+ scopes: ['tickets'],
+ })
+ )
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot portal 12345',
+ auditMetadata: { hubspotHubId: '12345' },
+ storedMetadata: { hubId: '12345', appId: '222', userId: '111' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ expectPrimaryCall()
+ })
+
+ it('falls back to account-info on primary 404 and succeeds with portalId', async () => {
+ mockFetch
+ .mockResolvedValueOnce(htmlResponse(404, '404 Not Found'))
+ .mockResolvedValueOnce(jsonResponse(200, { portalId: 123, uiDomain: 'app.hubspot.com' }))
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot portal 123',
+ auditMetadata: { hubspotHubId: '123' },
+ storedMetadata: { hubId: '123' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('throws invalid_credentials when primary 404 and fallback returns 401', async () => {
+ mockFetch
+ .mockResolvedValueOnce(htmlResponse(404, '404 Not Found'))
+ .mockResolvedValueOnce(
+ jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' })
+ )
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('treats primary 400 with fallback 403 as a live token without account-info access', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(400, { status: 'error', message: 'bad request' }))
+ .mockResolvedValueOnce(jsonResponse(403, { status: 'error', category: 'MISSING_SCOPES' }))
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot private app',
+ auditMetadata: {},
+ storedMetadata: {},
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('throws invalid_credentials on primary 401 without calling the fallback', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' })
+ )
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on primary 503', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(503, { message: 'unavailable' }))
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 503,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on primary success body missing hubId', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { unexpected: true }))
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts
new file mode 100644
index 00000000000..457710e32c8
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts
@@ -0,0 +1,123 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info'
+const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details'
+
+interface HubspotTokenInfo {
+ hubId?: number
+ appId?: number
+ userId?: number
+ scopes?: string[]
+}
+
+interface HubspotAccountInfo {
+ portalId?: number
+}
+
+/**
+ * Fallback verification via the Account Information API. Live probing shows
+ * the documented access-token-info route returns a bare 404 for invalid
+ * tokens, which is ambiguous (invalid token vs. missing route), while this
+ * regular API route answers with a proper JSON 401 for bad tokens. A 200
+ * proves the token is live; 401 means it was rejected; 403 means the token
+ * authenticated but the app lacks account-info access — still a live token.
+ */
+async function verifyViaAccountInfo(
+ accessToken: string
+): Promise {
+ const res = await fetchProvider(
+ ACCOUNT_INFO_URL,
+ { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
+ 'account_info'
+ )
+ if (res.status === 403) {
+ return {
+ displayName: 'HubSpot private app',
+ auditMetadata: {},
+ storedMetadata: {},
+ }
+ }
+ await throwForProviderResponse(res, 'account_info')
+
+ const info = await parseProviderJson(res, 'account_info')
+ const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined
+ return {
+ displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app',
+ auditMetadata: hubId ? { hubspotHubId: hubId } : {},
+ storedMetadata: hubId ? { hubId } : {},
+ }
+}
+
+/**
+ * Validates a HubSpot private app access token.
+ *
+ * Primary path: the documented access-token-info endpoint, sending both the
+ * JSON body (`tokenKey`) and the `Authorization: Bearer` header — the header
+ * is optional for NA (`pat-na1`) tokens but required for EU (`pat-eu1`)
+ * tokens, so one code path covers both regions.
+ *
+ * Live probing shows that endpoint returns a bare 404 (HTML) when the token
+ * is not recognized, so a 404 falls back to the Account Information API,
+ * which distinguishes a rejected token (JSON 401) from a live one (200/403).
+ *
+ * The display name is always `HubSpot portal {hubId}` — the account-info
+ * `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), not a
+ * portal-specific name, so it is not used.
+ */
+export async function validateHubspotServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const accessToken = fields.apiToken
+
+ const res = await fetchProvider(
+ TOKEN_INFO_URL,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ tokenKey: accessToken }),
+ },
+ 'access_token_info'
+ )
+ if (res.status === 404 || res.status === 400 || res.status === 403) {
+ // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s
+ // malformed ones); a 403 here is unexpected since the route needs no
+ // scopes, so it also defers to the fallback rather than blaming the
+ // token. Resolve via a regular API route with JSON errors.
+ await readProviderErrorSnippet(res)
+ return verifyViaAccountInfo(accessToken)
+ }
+ await throwForProviderResponse(res, 'access_token_info')
+
+ const tokenInfo = await parseProviderJson(res, 'access_token_info')
+ if (typeof tokenInfo?.hubId !== 'number') {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'access_token_info',
+ reason: 'missing hubId in response',
+ })
+ }
+
+ const hubId = String(tokenInfo.hubId)
+
+ const storedMetadata: Record = { hubId }
+ if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId)
+ if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId)
+
+ return {
+ displayName: `HubSpot portal ${hubId}`,
+ auditMetadata: { hubspotHubId: hubId },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts
new file mode 100644
index 00000000000..3007d161370
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts
@@ -0,0 +1,182 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateLinearServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns viewer and organization metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ viewer: { id: 'viewer-1', name: 'Jane Ops', email: 'jane@acme.com' },
+ organization: { id: 'org-1', name: 'Acme' },
+ },
+ })
+ )
+
+ const result = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' })
+
+ expect(result).toEqual({
+ displayName: 'Acme',
+ auditMetadata: { linearOrganizationId: 'org-1' },
+ storedMetadata: { viewerId: 'viewer-1', organizationId: 'org-1' },
+ })
+
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('https://api.linear.app/graphql')
+ expect(init.method).toBe('POST')
+ const headers = init.headers as Record
+ expect(headers.Authorization).toBe(linearAuthorizationHeader('lin_api_abc'))
+ expect(headers.Authorization).toBe('lin_api_abc')
+ expect(headers.Authorization).not.toContain('Bearer ')
+ expect(JSON.parse(init.body as string)).toEqual({
+ query: '{ viewer { id name email } organization { id name } }',
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(401, {
+ errors: [
+ { message: 'Authentication required', extensions: { type: 'authentication_error' } },
+ ],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws invalid_credentials on 200 with authentication errors', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [{ message: 'Not authorized', extensions: { code: 'authentication_error' } }],
+ })
+ )
+
+ await expect(
+ validateLinearServiceAccount({ apiToken: 'lin_api_revoked' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 200,
+ })
+ })
+
+ it('throws provider_unavailable on 400 with a rate-limit body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ errors: [{ message: 'Rate limit exceeded', extensions: { code: 'RATELIMITED' } }],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 400,
+ })
+ })
+
+ it('throws invalid_credentials on 400 with an authentication body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ errors: [
+ { message: 'Authentication required', extensions: { code: 'authentication_error' } },
+ ],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 400,
+ })
+ })
+
+ it('throws provider_unavailable on an ambiguous 400 body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, { errors: [{ message: 'Malformed request' }] })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 400,
+ })
+ })
+
+ it('throws invalid_credentials on 200 with a forbidden error', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [{ message: 'You do not have access', extensions: { type: 'Forbidden' } }],
+ })
+ )
+
+ await expect(
+ validateLinearServiceAccount({ apiToken: 'lin_api_scoped' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 200,
+ })
+ })
+
+ it('throws provider_unavailable on a non-JSON 200 body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('proxy error', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when fetch rejects with a network error', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: [{ message: 'Internal error' }] }))
+
+ const error = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts
new file mode 100644
index 00000000000..ea4e297f374
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts
@@ -0,0 +1,142 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'
+
+const VIEWER_QUERY = '{ viewer { id name email } organization { id name } }'
+
+interface LinearGraphQLError {
+ message?: string
+ extensions?: {
+ code?: string
+ type?: string
+ }
+}
+
+interface LinearViewerResponse {
+ data?: {
+ viewer?: {
+ id?: string
+ name?: string | null
+ email?: string | null
+ }
+ organization?: {
+ id?: string
+ name?: string | null
+ }
+ }
+ errors?: LinearGraphQLError[]
+}
+
+/**
+ * Linear does not officially document the HTTP status or extension code for a
+ * rejected key (community reports both 400 and 401, lowercase
+ * `authentication_error` in either `extensions.code` or `extensions.type`),
+ * so match "authentication" case-insensitively across message and extensions.
+ * Forbidden-type errors (a permissions-scoped key that cannot read `viewer`)
+ * are also treated as credential failures — such a key is unusable for Sim
+ * tools.
+ */
+function hasCredentialError(errors: LinearGraphQLError[] | undefined): boolean {
+ if (!errors) return false
+ return errors.some((error) => {
+ const haystack = [error.message, error.extensions?.code, error.extensions?.type]
+ return haystack.some(
+ (value) =>
+ typeof value === 'string' && /authentication|forbidden|not.?authorized/i.test(value)
+ )
+ })
+}
+
+/**
+ * Validates a Linear personal API key by running the `viewer` query against
+ * the fixed GraphQL endpoint. The Authorization header is built with the same
+ * helper the runtime tools use (`linearAuthorizationHeader`): personal
+ * `lin_api_` keys go bare, anything else gets the `Bearer` prefix — so
+ * validation exercises the exact header shape tools will send. GraphQL can
+ * return transport 200 with an `errors` array, so both the HTTP status and
+ * the body are inspected. Linear also returns HTTP 400 for rate/complexity
+ * limits (`RATELIMITED`), so a 400 only maps to `invalid_credentials` when
+ * the body carries authentication evidence.
+ */
+export async function validateLinearServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ LINEAR_GRAPHQL_URL,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: linearAuthorizationHeader(fields.apiToken),
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query: VIEWER_QUERY }),
+ },
+ 'viewer'
+ )
+
+ if (!res.ok) {
+ const body = await readProviderErrorSnippet(res)
+ if (res.status === 401 || res.status === 403) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ if (res.status === 400) {
+ if (!/ratelimit|complexity/i.test(body) && /authenticat/i.test(body)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+
+ const payload = await parseProviderJson(res, 'viewer')
+ if (hasCredentialError(payload.errors)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ reason: 'GraphQL authentication/authorization error in 200 response',
+ })
+ }
+
+ const viewer = payload.data?.viewer
+ if (!viewer?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'viewer',
+ reason: 'missing viewer in response',
+ })
+ }
+
+ const organization = payload.data?.organization
+ const storedMetadata: Record = { viewerId: viewer.id }
+ const auditMetadata: Record = {}
+ if (organization?.id) {
+ storedMetadata.organizationId = organization.id
+ auditMetadata.linearOrganizationId = organization.id
+ }
+
+ return {
+ displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace',
+ auditMetadata,
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts
new file mode 100644
index 00000000000..e96e7006b72
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts
@@ -0,0 +1,164 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateMondayServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns account display name and metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ me: { id: '12345', name: 'Jane Ops', email: 'jane@example.com' },
+ account: { id: 987, name: 'Acme', slug: 'acme' },
+ },
+ })
+ )
+
+ const result = await validateMondayServiceAccount({ apiToken: 'eyJtoken' })
+
+ expect(result).toEqual({
+ displayName: 'Acme',
+ auditMetadata: { mondayAccountId: '987' },
+ storedMetadata: { accountId: '987', accountSlug: 'acme', userId: '12345' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.monday.com/v2', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'eyJtoken',
+ 'API-Version': '2026-04',
+ },
+ body: JSON.stringify({
+ query: 'query { me { id name email } account { id name slug } }',
+ }),
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws invalid_credentials on 200 with errors array', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { errors: [{ message: 'Not Authenticated' }] })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'stale' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(200)
+ })
+
+ it('throws invalid_credentials on 200 with top-level error_message', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { error_message: 'Not Authenticated' }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'stale' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(200)
+ })
+
+ it('throws provider_unavailable on 200 with INTERNAL_SERVER_ERROR extensions', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ {
+ message: 'Internal server error',
+ extensions: { code: 'INTERNAL_SERVER_ERROR', status_code: 500 },
+ },
+ ],
+ })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable when the provider-side error is not first in the array', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ { message: 'Field deprecation warning' },
+ {
+ message: 'Rate limit exceeded',
+ extensions: { code: 'RATE_LIMIT_EXCEEDED', status_code: 429 },
+ },
+ ],
+ })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('omits mondayAccountId from auditMetadata when account is absent', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { me: { id: '12345', name: 'Jane Ops', email: 'jane@example.com' } },
+ })
+ )
+
+ const result = await validateMondayServiceAccount({ apiToken: 'eyJtoken' })
+
+ expect(result.auditMetadata).toEqual({})
+ expect(result.auditMetadata).not.toHaveProperty('mondayAccountId')
+ expect(result.displayName).toBe('Jane Ops')
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('accepts a valid token when warnings accompany successful me data', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { me: { id: 77, name: 'Bot User' }, account: { id: 5, name: 'Acme', slug: 'acme' } },
+ errors: [{ message: 'Deprecated field usage', extensions: { code: 'DEPRECATED' } }],
+ })
+ )
+ const result = await validateMondayServiceAccount({ apiToken: 'token' })
+ expect(result.displayName).toBe('Acme')
+ expect(result.storedMetadata?.userId).toBe('77')
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts
new file mode 100644
index 00000000000..9ff853d71ff
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts
@@ -0,0 +1,132 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils'
+
+const VALIDATION_QUERY = 'query { me { id name email } account { id name slug } }'
+
+interface MondayValidationBody {
+ data?: {
+ me?: { id?: string | number; name?: string; email?: string }
+ account?: { id?: string | number; name?: string; slug?: string }
+ }
+ errors?: Array<{
+ message?: string
+ extensions?: { code?: string; status_code?: number }
+ }>
+ error_message?: string
+}
+
+const SERVER_SIDE_ERROR_CODE = /internal|server_error|unavailable|rate_limit|complexity/i
+
+/**
+ * Detects a monday GraphQL error that signals a provider-side failure rather
+ * than a rejected token. monday marks these via `extensions.status_code`
+ * (>= 500) or an `extensions.code` such as `INTERNAL_SERVER_ERROR`,
+ * `RATE_LIMIT_EXCEEDED`, or a complexity budget error, per the monday API
+ * error documentation. All entries are scanned — the provider-side error is
+ * not guaranteed to be first in the array.
+ */
+function isProviderSideError(body: MondayValidationBody): boolean {
+ if (!Array.isArray(body.errors)) return false
+ return body.errors.some((error) => {
+ const extensions = error?.extensions
+ if (!extensions) return false
+ if (typeof extensions.status_code === 'number' && extensions.status_code >= 500) return true
+ return typeof extensions.code === 'string' && SERVER_SIDE_ERROR_CODE.test(extensions.code)
+ })
+}
+
+/**
+ * Mirrors `extractMondayError` from `@/tools/monday/utils`: monday returns
+ * HTTP 200 for application-level failures, signalled by a GraphQL `errors`
+ * array or a top-level `error_message` field.
+ */
+function extractBodyError(body: MondayValidationBody): string | null {
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ const messages = body.errors.map((e) => e.message).filter(Boolean)
+ return messages.length > 0 ? messages.join('; ') : 'Unknown Monday.com API error'
+ }
+ if (body.error_message) {
+ return body.error_message
+ }
+ return null
+}
+
+/**
+ * Validates a monday.com personal API token by running a `me`/`account`
+ * GraphQL query. monday tools send the token as a bare `Authorization`
+ * header (no `Bearer` prefix) with a pinned `API-Version`, so validation
+ * reuses `mondayHeaders` to exercise exactly the shape tools use.
+ *
+ * monday returns HTTP 200 for application-level errors, so a 200 response
+ * is only a success once the body carries no `errors` array or
+ * `error_message` and `data.me.id` is present.
+ */
+export async function validateMondayServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ MONDAY_API_URL,
+ {
+ method: 'POST',
+ headers: mondayHeaders(fields.apiToken),
+ body: JSON.stringify({ query: VALIDATION_QUERY }),
+ },
+ 'me'
+ )
+ await throwForProviderResponse(res, 'me')
+
+ const body = await parseProviderJson(res, 'me')
+
+ const me = body.data?.me
+ const account = body.data?.account
+
+ // monday can attach warning-class GraphQL errors (e.g. deprecations) to an
+ // otherwise successful response — a present `me.id` proves the token
+ // authenticated, so errors are only classified when the data is missing.
+ const bodyError = me?.id ? undefined : extractBodyError(body)
+ if (bodyError) {
+ if (isProviderSideError(body)) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ body: bodyError,
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body: bodyError,
+ })
+ }
+
+ if (!me?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ reason: 'missing me.id in response',
+ })
+ }
+
+ const userId = String(me.id)
+ const accountId = account?.id != null ? String(account.id) : ''
+ const storedMetadata: Record = { accountId, userId }
+ if (account?.slug) {
+ storedMetadata.accountSlug = account.slug
+ }
+ const auditMetadata: Record = {}
+ if (accountId) {
+ auditMetadata.mondayAccountId = accountId
+ }
+
+ return {
+ displayName: account?.name || me.name || me.email || `monday user ${userId}`,
+ auditMetadata,
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts
new file mode 100644
index 00000000000..fad5f227b30
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateNotionServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns bot metadata on success with a name', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ object: 'user',
+ id: 'bot-123',
+ type: 'bot',
+ name: 'Ops Integration',
+ bot: { workspace_name: 'Acme Workspace' },
+ })
+ )
+
+ const result = await validateNotionServiceAccount({ apiToken: 'ntn_abc' })
+
+ expect(result).toEqual({
+ displayName: 'Ops Integration',
+ auditMetadata: { notionBotId: 'bot-123' },
+ storedMetadata: { botId: 'bot-123', workspaceName: 'Acme Workspace' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.notion.com/v1/users/me', {
+ headers: {
+ Authorization: 'Bearer ntn_abc',
+ 'Notion-Version': '2022-06-28',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to workspace name when name is empty', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ object: 'user',
+ id: 'bot-456',
+ type: 'bot',
+ name: '',
+ bot: { workspace_name: 'Acme Workspace' },
+ })
+ )
+
+ const result = await validateNotionServiceAccount({ apiToken: 'secret_legacy' })
+
+ expect(result.displayName).toBe('Acme Workspace')
+ expect(result.auditMetadata).toEqual({ notionBotId: 'bot-456' })
+ expect(result.storedMetadata).toEqual({
+ botId: 'bot-456',
+ workspaceName: 'Acme Workspace',
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { object: 'error', code: 'unauthorized' }))
+
+ await expect(validateNotionServiceAccount({ apiToken: 'ntn_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable when a 200 body is not JSON', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('gateway', { status: 200 }))
+
+ const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 502', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(502, { object: 'error' }))
+
+ const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts
new file mode 100644
index 00000000000..4321ba5f3c4
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts
@@ -0,0 +1,69 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Pinned to the same API version Sim's Notion tools send
+ * (`apps/sim/tools/notion/read.ts`) so validation and execution exercise the
+ * same API surface.
+ */
+const NOTION_VERSION = '2022-06-28'
+
+interface NotionBotUser {
+ id?: string
+ name?: string | null
+ bot?: {
+ workspace_name?: string | null
+ }
+}
+
+/**
+ * Validates a Notion internal integration secret by calling
+ * `GET /v1/users/me`, which is documented to succeed with any capability
+ * level. Tokens are treated as opaque strings — Notion explicitly advises
+ * against prefix/regex validation (`ntn_` and legacy `secret_` are both
+ * valid).
+ */
+export async function validateNotionServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ 'https://api.notion.com/v1/users/me',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ 'Notion-Version': NOTION_VERSION,
+ Accept: 'application/json',
+ },
+ },
+ 'users_me'
+ )
+ await throwForProviderResponse(res, 'users_me')
+
+ const me = await parseProviderJson(res, 'users_me')
+ if (!me.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'users_me',
+ reason: 'missing id in response',
+ })
+ }
+
+ const workspaceName = me.bot?.workspace_name || undefined
+ const storedMetadata: Record = { botId: me.id }
+ if (workspaceName) {
+ storedMetadata.workspaceName = workspaceName
+ }
+
+ return {
+ displayName: me.name || workspaceName || 'Notion integration',
+ auditMetadata: { notionBotId: me.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts
new file mode 100644
index 00000000000..f8784e9bb5d
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts
@@ -0,0 +1,174 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateShopifyServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns shop metadata and the normalized domain on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ shop: {
+ name: 'Acme Store',
+ myshopifyDomain: 'acme-store.myshopify.com',
+ },
+ },
+ })
+ )
+
+ const result = await validateShopifyServiceAccount({
+ apiToken: 'shpat_abc',
+ domain: 'https://Acme-Store.myshopify.com/',
+ })
+
+ expect(result).toEqual({
+ displayName: 'Acme Store',
+ auditMetadata: { shopifyShopDomain: 'acme-store.myshopify.com' },
+ storedMetadata: { shopDomain: 'acme-store.myshopify.com', shopName: 'Acme Store' },
+ normalizedDomain: 'acme-store.myshopify.com',
+ })
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://acme-store.myshopify.com/admin/api/2024-10/graphql.json',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': 'shpat_abc',
+ },
+ body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }),
+ signal: expect.any(AbortSignal),
+ }
+ )
+ })
+
+ it.each(['evil.com', 'localhost', 'sub.myshopify.com.evil.com'])(
+ 'rejects non-Shopify host %s without fetching',
+ async (domain) => {
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'site_not_found',
+ status: 400,
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ }
+ )
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { errors: 'Invalid API key' }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_bad', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws site_not_found on 404', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(404, { errors: 'Not Found' }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'no-shop.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'site_not_found',
+ status: 404,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: 'Internal Server Error' }))
+
+ const error = await validateShopifyServiceAccount({
+ apiToken: 'shpat_abc',
+ domain: 'acme.myshopify.com',
+ }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('throws provider_unavailable when a 200 body carries GraphQL errors', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { errors: [{ message: 'Internal error' }] }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when a 200 body is not JSON', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('gateway error', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('maps an auth-shaped GraphQL error in a 200 response to invalid_credentials', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ { message: 'Invalid API key or access token (unrecognized login or wrong password)' },
+ ],
+ })
+ )
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_bad', domain: 'my-store.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('normalizes a pasted admin URL down to the bare store host', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { shop: { name: 'Probe', myshopifyDomain: 'my-store.myshopify.com' } },
+ })
+ )
+ const result = await validateShopifyServiceAccount({
+ apiToken: 'shpat_ok',
+ domain: 'https://my-store.myshopify.com/admin/settings?x=1',
+ })
+ expect(result.normalizedDomain).toBe('my-store.myshopify.com')
+ expect(mockFetch.mock.calls[0][0]).toContain('https://my-store.myshopify.com/admin/api')
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts
new file mode 100644
index 00000000000..4f71977b37a
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts
@@ -0,0 +1,141 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Pinned to match the Admin API version hardcoded in every Sim Shopify tool
+ * (`apps/sim/tools/shopify/*`) — bump both together. Note 2024-10 is retired;
+ * Shopify silently serves the oldest supported version for retired versions,
+ * so validation and tool runtime still hit the same effective API.
+ */
+const SHOPIFY_API_VERSION = '2024-10'
+
+/**
+ * SSRF guard: the Admin API must target the permanent `*.myshopify.com`
+ * host — exactly one label before `myshopify.com`, so lookalike hosts such as
+ * `sub.myshopify.com.evil.com` are rejected before any outbound fetch.
+ */
+const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/
+
+const SHOP_QUERY = '{ shop { name myshopifyDomain } }'
+
+interface ShopifyGraphqlError {
+ message?: string
+ extensions?: { code?: string }
+}
+
+interface ShopifyShopResponse {
+ data?: {
+ shop?: {
+ name?: string
+ myshopifyDomain?: string
+ }
+ }
+ errors?: ShopifyGraphqlError[] | unknown
+}
+
+/**
+ * Shopify can reject invalid or revoked `shpat_` tokens with HTTP 200 and a
+ * GraphQL error body instead of a 401, so auth-shaped GraphQL errors must map
+ * to `invalid_credentials` rather than a provider outage.
+ */
+function hasShopifyAuthError(errors: unknown): boolean {
+ if (!Array.isArray(errors)) return false
+ return errors.some((error: ShopifyGraphqlError) => {
+ const haystack = `${error?.message ?? ''} ${error?.extensions?.code ?? ''}`
+ return /access.?denied|unauthorized|invalid api key or access token|401/i.test(haystack)
+ })
+}
+
+/** Strips the protocol and trailing slashes and lowercases the store domain. */
+function normalizeShopifyDomain(rawDomain: string): string {
+ return rawDomain
+ .trim()
+ .replace(/^https?:\/\//i, '')
+ .replace(/[/?#].*$/, '')
+ .toLowerCase()
+}
+
+/**
+ * Validates a Shopify custom-app Admin API access token by running the
+ * scope-free `shop` query against the store's GraphQL Admin API — the exact
+ * URL and header shape every Sim Shopify tool uses. Unknown shops return 404
+ * (wildcard DNS means the host always resolves), which maps to
+ * `site_not_found`.
+ */
+export async function validateShopifyServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const domain = normalizeShopifyDomain(fields.domain ?? '')
+ if (!SHOPIFY_HOST_REGEX.test(domain)) {
+ throw new TokenServiceAccountValidationError('site_not_found', 400, {
+ step: 'host_validation',
+ domain,
+ reason: 'host is not a Shopify store domain (expected *.myshopify.com)',
+ })
+ }
+
+ const res = await fetchProvider(
+ `https://${domain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': fields.apiToken,
+ },
+ body: JSON.stringify({ query: SHOP_QUERY }),
+ },
+ 'shop_query'
+ )
+
+ if (res.status === 404) {
+ throw new TokenServiceAccountValidationError('site_not_found', 404, {
+ step: 'shop_query',
+ domain,
+ body: await readProviderErrorSnippet(res),
+ })
+ }
+ await throwForProviderResponse(res, 'shop_query', { domain })
+
+ const payload = await parseProviderJson(res, 'shop_query')
+
+ const shop = payload.data?.shop
+ if (hasShopifyAuthError(payload.errors)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'shop_query',
+ domain,
+ reason: 'auth-shaped GraphQL error in 200 response',
+ })
+ }
+ if (payload.errors || !shop) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'shop_query',
+ domain,
+ reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response',
+ })
+ }
+
+ const shopName = typeof shop.name === 'string' && shop.name ? shop.name : undefined
+ const apiDomain =
+ typeof shop.myshopifyDomain === 'string'
+ ? normalizeShopifyDomain(shop.myshopifyDomain)
+ : undefined
+ const canonicalDomain = apiDomain && SHOPIFY_HOST_REGEX.test(apiDomain) ? apiDomain : domain
+ const storedMetadata: Record = { shopDomain: canonicalDomain }
+ if (shopName) storedMetadata.shopName = shopName
+
+ return {
+ displayName: shopName ?? canonicalDomain,
+ auditMetadata: { shopifyShopDomain: canonicalDomain },
+ storedMetadata,
+ normalizedDomain: canonicalDomain,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts
new file mode 100644
index 00000000000..5e78de2f1d6
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockEnv } = vi.hoisted(() => ({
+ mockEnv: { TRELLO_API_KEY: undefined as string | undefined },
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: mockEnv,
+}))
+
+import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello'
+
+const FIELDS = { apiToken: 'ATTA0a1b2c3d' }
+
+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('validateTrelloServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ mockEnv.TRELLO_API_KEY = 'sim-api-key'
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, { id: 'abc123', fullName: 'Sim Bot', username: 'simbot' })
+ )
+
+ const result = await validateTrelloServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'Sim Bot',
+ auditMetadata: { trelloMemberId: 'abc123' },
+ storedMetadata: { memberId: 'abc123', username: 'simbot' },
+ })
+
+ const [url] = mockFetch.mock.calls[0]
+ const parsed = new URL(url)
+ expect(parsed.origin + parsed.pathname).toBe('https://api.trello.com/1/members/me')
+ expect(parsed.searchParams.get('key')).toBe('sim-api-key')
+ expect(parsed.searchParams.get('token')).toBe('ATTA0a1b2c3d')
+ expect(parsed.searchParams.get('fields')).toBe('id,fullName,username')
+ })
+
+ it('throws invalid_credentials on 401 with an invalid token body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'invalid token'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable on 401 with an invalid key body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'invalid key'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 401,
+ logDetail: { step: 'members_me', reason: 'Trello rejected the server API key' },
+ })
+ })
+
+ it('throws invalid_credentials on 401 with any other body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'unauthorized'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable on a non-JSON 200 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(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { message: 'unavailable' }))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 500,
+ })
+ })
+
+ it('throws provider_unavailable without fetching when the API key is not configured', async () => {
+ mockEnv.TRELLO_API_KEY = undefined
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 500,
+ logDetail: { reason: 'Trello API key is not configured' },
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it('throws provider_unavailable on missing id in success body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { fullName: 'Sim Bot' }))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts
new file mode 100644
index 00000000000..209a43e8b2e
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts
@@ -0,0 +1,92 @@
+import { env } from '@/lib/core/config/env'
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const MEMBERS_ME_URL = 'https://api.trello.com/1/members/me'
+
+interface TrelloMember {
+ id?: string
+ fullName?: string
+ username?: string
+}
+
+/**
+ * Validates a Trello member token by calling `/1/members/me` with the token
+ * paired against Sim's own `TRELLO_API_KEY` — Trello binds tokens to the API
+ * key that authorized them, so a token minted under any other key 401s here
+ * exactly as it would at execution time. Both secrets travel as URL query
+ * params, so no request URL is ever included in error `logDetail` (only the
+ * step name and a body snippet, which never echoes the credentials). A 401 is
+ * disambiguated by body: Trello's `invalid key` means Sim's own key was
+ * rejected (`provider_unavailable`), while `invalid token` — or any other 401
+ * body — blames the pasted token (`invalid_credentials`).
+ */
+export async function validateTrelloServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const apiKey = env.TRELLO_API_KEY
+ if (!apiKey) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 500, {
+ reason: 'Trello API key is not configured',
+ })
+ }
+
+ const url = new URL(MEMBERS_ME_URL)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', fields.apiToken)
+ url.searchParams.set('fields', 'id,fullName,username')
+
+ const res = await fetchProvider(
+ url.toString(),
+ { headers: { Accept: 'application/json' } },
+ 'members_me'
+ )
+ if (res.status === 401) {
+ const body = await readProviderErrorSnippet(res)
+ if (body.includes('invalid token')) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'members_me',
+ body,
+ })
+ }
+ if (body.includes('invalid key')) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 401, {
+ step: 'members_me',
+ reason: 'Trello rejected the server API key',
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'members_me',
+ body,
+ })
+ }
+ await throwForProviderResponse(res, 'members_me')
+
+ const member = await parseProviderJson(res, 'members_me')
+ if (typeof member?.id !== 'string' || !member.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'members_me',
+ reason: 'missing id in response',
+ })
+ }
+
+ const storedMetadata: Record = { memberId: member.id }
+ if (typeof member.username === 'string' && member.username) {
+ storedMetadata.username = member.username
+ }
+
+ return {
+ displayName: member.fullName || member.username || `Trello member ${member.id}`,
+ auditMetadata: { trelloMemberId: member.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts
new file mode 100644
index 00000000000..50f8739d879
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox'
+
+const ME_URL = 'https://api.crmworkspace.com/v1/me'
+
+const FIELDS = { apiToken: '12345678901234567890123456789012' }
+
+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('validateWealthboxServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on Bearer success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ name: 'Bill Jones',
+ email: 'bill@example.com',
+ current_user: { id: 42, email: 'bill@example.com', name: 'Bill Jones' },
+ })
+ )
+
+ const result = await validateWealthboxServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'Bill Jones',
+ auditMetadata: { wealthboxUserId: '42' },
+ storedMetadata: { userId: '42', email: 'bill@example.com' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(ME_URL)
+ expect(init.headers.Authorization).toBe(`Bearer ${FIELDS.apiToken}`)
+ })
+
+ it('throws invalid_credentials when Bearer 401s but ACCESS_TOKEN succeeds', async () => {
+ mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => {
+ const headers = (init?.headers ?? {}) as Record
+ if (headers.Authorization) return jsonResponse(401, { error: 'No valid API key provided' })
+ if (headers.ACCESS_TOKEN) return jsonResponse(200, { name: 'Bill Jones' })
+ throw new Error('unexpected fetch headers')
+ })
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ logDetail: expect.objectContaining({
+ reason: 'token accepted only via ACCESS_TOKEN header — not compatible with Sim tools',
+ }),
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('throws invalid_credentials when both Bearer and ACCESS_TOKEN 401', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { error: 'No valid API key provided' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws invalid_credentials on 402 (expired Wealthbox trial)', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(402, { error: 'Wealthbox trial account has expired' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 402,
+ logDetail: { step: 'me', reason: 'wealthbox trial expired (402)' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 503,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts
new file mode 100644
index 00000000000..94e85821f33
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts
@@ -0,0 +1,105 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const ME_URL = 'https://api.crmworkspace.com/v1/me'
+
+interface WealthboxMeResponse {
+ name?: string
+ email?: string
+ current_user?: {
+ id?: number
+ email?: string
+ name?: string
+ }
+}
+
+/**
+ * Best-effort probe with the officially documented `ACCESS_TOKEN` header.
+ * Used only to distinguish "bogus token" from "real token that Wealthbox
+ * refuses over Bearer" in server logs — never to accept the credential,
+ * because Sim's Wealthbox tools send `Authorization: Bearer` at runtime.
+ */
+async function probeAccessTokenHeader(apiToken: string): Promise {
+ try {
+ const res = await fetchProvider(
+ ME_URL,
+ { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' } },
+ 'me-access-token-probe'
+ )
+ return res.ok
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Validates a Wealthbox personal API access token by calling `GET /v1/me`
+ * with `Authorization: Bearer` — the exact header shape Sim's Wealthbox tools
+ * use — so a passing validation empirically proves the token works with the
+ * existing tool code. If Bearer is rejected but the documented `ACCESS_TOKEN`
+ * header succeeds, the token is real yet unusable by Sim's tools, so it is
+ * still rejected as `invalid_credentials` with a distinguishing log detail.
+ *
+ * A 402 is documented by Wealthbox as "Wealthbox trial account has expired"
+ * and maps to `invalid_credentials` — retrying later would never succeed, so
+ * the user is prompted to check their Wealthbox account instead.
+ */
+export async function validateWealthboxServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const apiToken = fields.apiToken
+
+ const res = await fetchProvider(
+ ME_URL,
+ { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' } },
+ 'me'
+ )
+
+ if (res.status === 402) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ reason: 'wealthbox trial expired (402)',
+ })
+ }
+
+ if (res.status === 401 || res.status === 403) {
+ const body = await readProviderErrorSnippet(res)
+ const accessTokenHeaderWorks = await probeAccessTokenHeader(apiToken)
+ if (accessTokenHeaderWorks) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body,
+ reason: 'token accepted only via ACCESS_TOKEN header — not compatible with Sim tools',
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body,
+ })
+ }
+ await throwForProviderResponse(res, 'me')
+
+ const me = await parseProviderJson(res, 'me')
+
+ const displayName = me.name || me.email || 'Wealthbox account'
+ const userId = typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined
+ const email = me.email || me.current_user?.email
+
+ const auditMetadata: Record = {}
+ if (userId) auditMetadata.wealthboxUserId = userId
+
+ const storedMetadata: Record = {}
+ if (userId) storedMetadata.userId = userId
+ if (email) storedMetadata.email = email
+
+ return { displayName, auditMetadata, storedMetadata }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts
new file mode 100644
index 00000000000..1cb3f9018c8
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateWebflowServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns site display name and metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ sites: [{ id: 'site123', displayName: 'Acme Marketing', shortName: 'acme-marketing' }],
+ })
+ )
+
+ const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' })
+
+ expect(result).toEqual({
+ displayName: 'Acme Marketing',
+ auditMetadata: { webflowSiteId: 'site123' },
+ storedMetadata: { siteId: 'site123', siteName: 'Acme Marketing' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.webflow.com/v2/sites', {
+ headers: {
+ Authorization: 'Bearer wf-token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to shortName when displayName is absent', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { sites: [{ id: 'site456', shortName: 'acme' }] })
+ )
+
+ const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' })
+
+ expect(result.displayName).toBe('acme')
+ expect(result.storedMetadata).toEqual({ siteId: 'site456', siteName: 'acme' })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 401 }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('gateway timeout', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 200 with empty sites array', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { sites: [] }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts
new file mode 100644
index 00000000000..034558532b2
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts
@@ -0,0 +1,59 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+interface WebflowSite {
+ id?: string
+ displayName?: string
+ shortName?: string
+}
+
+/**
+ * Validates a Webflow site API token by listing sites via the Data API v2.
+ * A site token is bound to exactly one site, so `GET /v2/sites` returns that
+ * site and doubles as the whoami-equivalent (site tokens cannot call the
+ * authorization endpoints). Requires the `sites:read` scope — a token missing
+ * it surfaces as 401/403, which maps to `invalid_credentials`; any other
+ * non-2xx means Webflow is unavailable. A 200 with an empty `sites` array is
+ * treated as `provider_unavailable` since a site token should always list its
+ * one site.
+ */
+export async function validateWebflowServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const sitesRes = await fetchProvider(
+ 'https://api.webflow.com/v2/sites',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'list_sites'
+ )
+ await throwForProviderResponse(sitesRes, 'list_sites')
+
+ const payload = await parseProviderJson<{ sites?: WebflowSite[] }>(sitesRes, 'list_sites')
+ const site = payload.sites?.[0]
+ if (!site?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'list_sites',
+ reason: 'missing or empty sites array in response',
+ })
+ }
+
+ const displayName = site.displayName || site.shortName || 'Webflow site'
+
+ return {
+ displayName,
+ auditMetadata: { webflowSiteId: site.id },
+ storedMetadata: { siteId: site.id, siteName: displayName },
+ }
+}
diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts
index dc61fd34759..34712123df8 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 { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth'
@@ -38,7 +39,8 @@ function asServiceAccountProviderId(
if (
value === 'google-service-account' ||
value === 'atlassian-service-account' ||
- value === SLACK_CUSTOM_BOT_PROVIDER_ID
+ value === SLACK_CUSTOM_BOT_PROVIDER_ID ||
+ isTokenServiceAccountProviderId(value)
) {
return value
}
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index d472d8d611c..def1084fab7 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -589,6 +589,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Airtable',
description: 'Manage Airtable bases, tables, and records.',
providerId: 'airtable',
+ serviceAccountProviderId: 'airtable-service-account',
icon: AirtableIcon,
baseProviderIcon: AirtableIcon,
scopes: [
@@ -610,6 +611,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Notion',
description: 'Connect to your Notion workspace to manage pages and databases.',
providerId: 'notion',
+ serviceAccountProviderId: 'notion-service-account',
icon: NotionIcon,
baseProviderIcon: NotionIcon,
scopes: [],
@@ -625,6 +627,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Linear',
description: 'Manage issues and projects in Linear.',
providerId: 'linear',
+ serviceAccountProviderId: 'linear-service-account',
icon: LinearIcon,
baseProviderIcon: LinearIcon,
scopes: ['read', 'write'],
@@ -640,6 +643,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Monday.com',
description: 'Manage boards, items, and groups in Monday.com.',
providerId: 'monday',
+ serviceAccountProviderId: 'monday-service-account',
icon: MondayIcon,
baseProviderIcon: MondayIcon,
scopes: [
@@ -701,6 +705,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Shopify',
description: 'Manage products, orders, and customers in your Shopify store.',
providerId: 'shopify',
+ serviceAccountProviderId: 'shopify-service-account',
icon: ShopifyIcon,
baseProviderIcon: ShopifyIcon,
scopes: [
@@ -797,6 +802,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Wealthbox',
description: 'Manage contacts, notes, and tasks in your Wealthbox CRM.',
providerId: 'wealthbox',
+ serviceAccountProviderId: 'wealthbox-service-account',
icon: WealthboxIcon,
baseProviderIcon: WealthboxIcon,
scopes: ['login', 'data'],
@@ -812,6 +818,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Webflow',
description: 'Manage Webflow CMS collections, sites, and content.',
providerId: 'webflow',
+ serviceAccountProviderId: 'webflow-service-account',
icon: WebflowIcon,
baseProviderIcon: WebflowIcon,
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'],
@@ -827,6 +834,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Trello',
description: 'Manage Trello boards, cards, and workflows.',
providerId: 'trello',
+ serviceAccountProviderId: 'trello-service-account',
icon: TrelloIcon,
baseProviderIcon: TrelloIcon,
scopes: ['read', 'write'],
@@ -842,6 +850,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Asana',
description: 'Manage Asana projects, tasks, and workflows.',
providerId: 'asana',
+ serviceAccountProviderId: 'asana-service-account',
icon: AsanaIcon,
baseProviderIcon: AsanaIcon,
scopes: ['default'],
@@ -857,6 +866,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Attio',
description: 'Manage records, notes, tasks, lists, comments, and more in Attio CRM.',
providerId: 'attio',
+ serviceAccountProviderId: 'attio-service-account',
icon: AttioIcon,
baseProviderIcon: AttioIcon,
scopes: [
@@ -882,6 +892,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Cal.com',
description: 'Manage Cal.com bookings, event types, and schedules.',
providerId: 'calcom',
+ serviceAccountProviderId: 'calcom-service-account',
icon: CalComIcon,
baseProviderIcon: CalComIcon,
scopes: [],
@@ -935,6 +946,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'HubSpot',
description: 'Access and manage your HubSpot CRM data.',
providerId: 'hubspot',
+ serviceAccountProviderId: 'hubspot-service-account',
icon: HubspotIcon,
baseProviderIcon: HubspotIcon,
scopes: [
diff --git a/apps/sim/tools/linear/add_label_to_issue.ts b/apps/sim/tools/linear/add_label_to_issue.ts
index 1b320c3f3c0..3067d332c53 100644
--- a/apps/sim/tools/linear/add_label_to_issue.ts
+++ b/apps/sim/tools/linear/add_label_to_issue.ts
@@ -1,4 +1,5 @@
import type { LinearAddLabelResponse, LinearAddLabelToIssueParams } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearAddLabelToIssueTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearAddLabelToIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/add_label_to_project.ts b/apps/sim/tools/linear/add_label_to_project.ts
index 54408b2f46c..e2af0766e65 100644
--- a/apps/sim/tools/linear/add_label_to_project.ts
+++ b/apps/sim/tools/linear/add_label_to_project.ts
@@ -2,6 +2,7 @@ import type {
LinearAddLabelToProjectParams,
LinearAddLabelToProjectResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearAddLabelToProjectTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearAddLabelToProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_issue.ts b/apps/sim/tools/linear/archive_issue.ts
index a166ee76a63..fb8128f51a7 100644
--- a/apps/sim/tools/linear/archive_issue.ts
+++ b/apps/sim/tools/linear/archive_issue.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveIssueParams, LinearArchiveIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveIssueTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_label.ts b/apps/sim/tools/linear/archive_label.ts
index e24250b6e22..0f9f9fa3e89 100644
--- a/apps/sim/tools/linear/archive_label.ts
+++ b/apps/sim/tools/linear/archive_label.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveLabelParams, LinearArchiveLabelResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveLabelTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_project.ts b/apps/sim/tools/linear/archive_project.ts
index b7d322b668c..a45e20542b7 100644
--- a/apps/sim/tools/linear/archive_project.ts
+++ b/apps/sim/tools/linear/archive_project.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveProjectParams, LinearArchiveProjectResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveProjectTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_attachment.ts b/apps/sim/tools/linear/create_attachment.ts
index 03019de9122..1965fd68895 100644
--- a/apps/sim/tools/linear/create_attachment.ts
+++ b/apps/sim/tools/linear/create_attachment.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateAttachmentResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateAttachmentTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_comment.ts b/apps/sim/tools/linear/create_comment.ts
index 966c9bad2ff..52417ae37d3 100644
--- a/apps/sim/tools/linear/create_comment.ts
+++ b/apps/sim/tools/linear/create_comment.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCommentParams, LinearCreateCommentResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCommentTool: ToolConfig<
@@ -40,7 +41,7 @@ export const linearCreateCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_customer.ts b/apps/sim/tools/linear/create_customer.ts
index 241a9c8dce1..bc249eb4356 100644
--- a/apps/sim/tools/linear/create_customer.ts
+++ b/apps/sim/tools/linear/create_customer.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCustomerParams, LinearCreateCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerTool: ToolConfig<
@@ -82,7 +83,7 @@ export const linearCreateCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_request.ts b/apps/sim/tools/linear/create_customer_request.ts
index e07c9395864..2414440241c 100644
--- a/apps/sim/tools/linear/create_customer_request.ts
+++ b/apps/sim/tools/linear/create_customer_request.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateCustomerRequestParams,
LinearCreateCustomerRequestResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerRequestTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerRequestTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_status.ts b/apps/sim/tools/linear/create_customer_status.ts
index 5e1467d5ec8..fb22480727f 100644
--- a/apps/sim/tools/linear/create_customer_status.ts
+++ b/apps/sim/tools/linear/create_customer_status.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateCustomerStatusResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerStatusTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_tier.ts b/apps/sim/tools/linear/create_customer_tier.ts
index bdb0df977c8..5335c509db6 100644
--- a/apps/sim/tools/linear/create_customer_tier.ts
+++ b/apps/sim/tools/linear/create_customer_tier.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateCustomerTierResponse,
} from '@/tools/linear/types'
import { CUSTOMER_TIER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerTierTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_cycle.ts b/apps/sim/tools/linear/create_cycle.ts
index da3d67b5544..04e23390ba3 100644
--- a/apps/sim/tools/linear/create_cycle.ts
+++ b/apps/sim/tools/linear/create_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCycleParams, LinearCreateCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCycleTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearCreateCycleTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_favorite.ts b/apps/sim/tools/linear/create_favorite.ts
index 429357e2e3a..27efdf50aca 100644
--- a/apps/sim/tools/linear/create_favorite.ts
+++ b/apps/sim/tools/linear/create_favorite.ts
@@ -1,4 +1,5 @@
import type { LinearCreateFavoriteParams, LinearCreateFavoriteResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateFavoriteTool: ToolConfig<
@@ -51,7 +52,7 @@ export const linearCreateFavoriteTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts
index adfc1af73b4..baff55786fc 100644
--- a/apps/sim/tools/linear/create_issue.ts
+++ b/apps/sim/tools/linear/create_issue.ts
@@ -1,5 +1,6 @@
import type { LinearCreateIssueParams, LinearCreateIssueResponse } from '@/tools/linear/types'
import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateIssueTool: ToolConfig =
@@ -110,7 +111,7 @@ export const linearCreateIssueTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_issue_relation.ts b/apps/sim/tools/linear/create_issue_relation.ts
index 82dfc8292f0..6d7d037be90 100644
--- a/apps/sim/tools/linear/create_issue_relation.ts
+++ b/apps/sim/tools/linear/create_issue_relation.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateIssueRelationParams,
LinearCreateIssueRelationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateIssueRelationTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearCreateIssueRelationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_label.ts b/apps/sim/tools/linear/create_label.ts
index 3ca63a52b1d..760da7301fa 100644
--- a/apps/sim/tools/linear/create_label.ts
+++ b/apps/sim/tools/linear/create_label.ts
@@ -1,5 +1,6 @@
import type { LinearCreateLabelParams, LinearCreateLabelResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateLabelTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearCreateLabelTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_project.ts b/apps/sim/tools/linear/create_project.ts
index b42c676eb9e..eeabd85cb58 100644
--- a/apps/sim/tools/linear/create_project.ts
+++ b/apps/sim/tools/linear/create_project.ts
@@ -1,5 +1,6 @@
import type { LinearCreateProjectParams, LinearCreateProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectTool: ToolConfig<
@@ -70,7 +71,7 @@ export const linearCreateProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_label.ts b/apps/sim/tools/linear/create_project_label.ts
index 6ad108f310c..3b3e2447afc 100644
--- a/apps/sim/tools/linear/create_project_label.ts
+++ b/apps/sim/tools/linear/create_project_label.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectLabelResponse,
} from '@/tools/linear/types'
import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectLabelTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_milestone.ts b/apps/sim/tools/linear/create_project_milestone.ts
index 022f8e13bcf..49c8e24666e 100644
--- a/apps/sim/tools/linear/create_project_milestone.ts
+++ b/apps/sim/tools/linear/create_project_milestone.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectMilestoneResponse,
} from '@/tools/linear/types'
import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectMilestoneTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearCreateProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_status.ts b/apps/sim/tools/linear/create_project_status.ts
index 5f63184daaf..e3d9cc92afd 100644
--- a/apps/sim/tools/linear/create_project_status.ts
+++ b/apps/sim/tools/linear/create_project_status.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectStatusResponse,
} from '@/tools/linear/types'
import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectStatusTool: ToolConfig<
@@ -68,7 +69,7 @@ export const linearCreateProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_update.ts b/apps/sim/tools/linear/create_project_update.ts
index f6ce64f9260..1d2de1bda1b 100644
--- a/apps/sim/tools/linear/create_project_update.ts
+++ b/apps/sim/tools/linear/create_project_update.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateProjectUpdateParams,
LinearCreateProjectUpdateResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectUpdateTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearCreateProjectUpdateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_workflow_state.ts b/apps/sim/tools/linear/create_workflow_state.ts
index 314dc748204..6a4f8b6d444 100644
--- a/apps/sim/tools/linear/create_workflow_state.ts
+++ b/apps/sim/tools/linear/create_workflow_state.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateWorkflowStateResponse,
} from '@/tools/linear/types'
import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateWorkflowStateTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearCreateWorkflowStateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/delete_attachment.ts b/apps/sim/tools/linear/delete_attachment.ts
index 44043c741e4..350174c0e44 100644
--- a/apps/sim/tools/linear/delete_attachment.ts
+++ b/apps/sim/tools/linear/delete_attachment.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteAttachmentParams,
LinearDeleteAttachmentResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteAttachmentTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_comment.ts b/apps/sim/tools/linear/delete_comment.ts
index 92c8acebb27..9d82c67b6f4 100644
--- a/apps/sim/tools/linear/delete_comment.ts
+++ b/apps/sim/tools/linear/delete_comment.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteCommentParams, LinearDeleteCommentResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCommentTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer.ts b/apps/sim/tools/linear/delete_customer.ts
index 9e66d1dd81c..dea9eb028cc 100644
--- a/apps/sim/tools/linear/delete_customer.ts
+++ b/apps/sim/tools/linear/delete_customer.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteCustomerParams, LinearDeleteCustomerResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer_status.ts b/apps/sim/tools/linear/delete_customer_status.ts
index 3e3beaf9a07..e461a54d8d8 100644
--- a/apps/sim/tools/linear/delete_customer_status.ts
+++ b/apps/sim/tools/linear/delete_customer_status.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteCustomerStatusParams,
LinearDeleteCustomerStatusResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerStatusTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer_tier.ts b/apps/sim/tools/linear/delete_customer_tier.ts
index d9d36183eeb..6e75df59186 100644
--- a/apps/sim/tools/linear/delete_customer_tier.ts
+++ b/apps/sim/tools/linear/delete_customer_tier.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteCustomerTierParams,
LinearDeleteCustomerTierResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerTierTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_issue.ts b/apps/sim/tools/linear/delete_issue.ts
index 1e558666e64..773f9dd8c98 100644
--- a/apps/sim/tools/linear/delete_issue.ts
+++ b/apps/sim/tools/linear/delete_issue.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteIssueParams, LinearDeleteIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteIssueTool: ToolConfig =
@@ -31,7 +32,7 @@ export const linearDeleteIssueTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/delete_issue_relation.ts b/apps/sim/tools/linear/delete_issue_relation.ts
index 5ebb8708b78..faea68134cd 100644
--- a/apps/sim/tools/linear/delete_issue_relation.ts
+++ b/apps/sim/tools/linear/delete_issue_relation.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteIssueRelationParams,
LinearDeleteIssueRelationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteIssueRelationTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteIssueRelationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project.ts b/apps/sim/tools/linear/delete_project.ts
index 24f49c0b409..a993401dba5 100644
--- a/apps/sim/tools/linear/delete_project.ts
+++ b/apps/sim/tools/linear/delete_project.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteProjectParams, LinearDeleteProjectResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_label.ts b/apps/sim/tools/linear/delete_project_label.ts
index a4bec4e2a40..31384e8858c 100644
--- a/apps/sim/tools/linear/delete_project_label.ts
+++ b/apps/sim/tools/linear/delete_project_label.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectLabelParams,
LinearDeleteProjectLabelResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectLabelTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_milestone.ts b/apps/sim/tools/linear/delete_project_milestone.ts
index 43637409fc1..7089f99235e 100644
--- a/apps/sim/tools/linear/delete_project_milestone.ts
+++ b/apps/sim/tools/linear/delete_project_milestone.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectMilestoneParams,
LinearDeleteProjectMilestoneResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectMilestoneTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_status.ts b/apps/sim/tools/linear/delete_project_status.ts
index 9785816344e..566c3c1fbc4 100644
--- a/apps/sim/tools/linear/delete_project_status.ts
+++ b/apps/sim/tools/linear/delete_project_status.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectStatusParams,
LinearDeleteProjectStatusResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectStatusTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/get_active_cycle.ts b/apps/sim/tools/linear/get_active_cycle.ts
index eda62de1102..7c8a06ea523 100644
--- a/apps/sim/tools/linear/get_active_cycle.ts
+++ b/apps/sim/tools/linear/get_active_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearGetActiveCycleParams, LinearGetActiveCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetActiveCycleTool: ToolConfig<
@@ -34,7 +35,7 @@ export const linearGetActiveCycleTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/get_customer.ts b/apps/sim/tools/linear/get_customer.ts
index 10d241698c8..4641629179d 100644
--- a/apps/sim/tools/linear/get_customer.ts
+++ b/apps/sim/tools/linear/get_customer.ts
@@ -1,5 +1,6 @@
import type { LinearGetCustomerParams, LinearGetCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetCustomerTool: ToolConfig =
@@ -32,7 +33,7 @@ export const linearGetCustomerTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_cycle.ts b/apps/sim/tools/linear/get_cycle.ts
index f5f9eb24083..d47184d4983 100644
--- a/apps/sim/tools/linear/get_cycle.ts
+++ b/apps/sim/tools/linear/get_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearGetCycleParams, LinearGetCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetCycleTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetCycleTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_issue.ts b/apps/sim/tools/linear/get_issue.ts
index e5f354c7fa6..8f6840e99fa 100644
--- a/apps/sim/tools/linear/get_issue.ts
+++ b/apps/sim/tools/linear/get_issue.ts
@@ -1,5 +1,6 @@
import type { LinearGetIssueParams, LinearGetIssueResponse } from '@/tools/linear/types'
import { ISSUE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetIssueTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetIssueTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_project.ts b/apps/sim/tools/linear/get_project.ts
index 71690a6ba09..b1fef8bef15 100644
--- a/apps/sim/tools/linear/get_project.ts
+++ b/apps/sim/tools/linear/get_project.ts
@@ -1,5 +1,6 @@
import type { LinearGetProjectParams, LinearGetProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetProjectTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetProjectTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_viewer.ts b/apps/sim/tools/linear/get_viewer.ts
index faf5a13938f..9e9a88ee8e5 100644
--- a/apps/sim/tools/linear/get_viewer.ts
+++ b/apps/sim/tools/linear/get_viewer.ts
@@ -1,5 +1,6 @@
import type { LinearGetViewerParams, LinearGetViewerResponse } from '@/tools/linear/types'
import { USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetViewerTool: ToolConfig = {
@@ -24,7 +25,7 @@ export const linearGetViewerTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_attachments.ts b/apps/sim/tools/linear/list_attachments.ts
index 0eac48b829b..074b3c4acd0 100644
--- a/apps/sim/tools/linear/list_attachments.ts
+++ b/apps/sim/tools/linear/list_attachments.ts
@@ -3,6 +3,7 @@ import type {
LinearListAttachmentsResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListAttachmentsTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListAttachmentsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_comments.ts b/apps/sim/tools/linear/list_comments.ts
index 05eb77103ae..a440ad8ec6d 100644
--- a/apps/sim/tools/linear/list_comments.ts
+++ b/apps/sim/tools/linear/list_comments.ts
@@ -1,5 +1,6 @@
import type { LinearListCommentsParams, LinearListCommentsResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCommentsTool: ToolConfig<
@@ -46,7 +47,7 @@ export const linearListCommentsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_requests.ts b/apps/sim/tools/linear/list_customer_requests.ts
index abf406e7680..35a1830ddae 100644
--- a/apps/sim/tools/linear/list_customer_requests.ts
+++ b/apps/sim/tools/linear/list_customer_requests.ts
@@ -2,6 +2,7 @@ import type {
LinearListCustomerRequestsParams,
LinearListCustomerRequestsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerRequestsTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListCustomerRequestsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_statuses.ts b/apps/sim/tools/linear/list_customer_statuses.ts
index b7f3ea7d76b..da3bf26c376 100644
--- a/apps/sim/tools/linear/list_customer_statuses.ts
+++ b/apps/sim/tools/linear/list_customer_statuses.ts
@@ -3,6 +3,7 @@ import type {
LinearListCustomerStatusesResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerStatusesTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListCustomerStatusesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_tiers.ts b/apps/sim/tools/linear/list_customer_tiers.ts
index cecfe9e653c..ab6f7cccffe 100644
--- a/apps/sim/tools/linear/list_customer_tiers.ts
+++ b/apps/sim/tools/linear/list_customer_tiers.ts
@@ -3,6 +3,7 @@ import type {
LinearListCustomerTiersResponse,
} from '@/tools/linear/types'
import { CUSTOMER_TIER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerTiersTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListCustomerTiersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customers.ts b/apps/sim/tools/linear/list_customers.ts
index 09db5ab6499..a4e5bc66f4c 100644
--- a/apps/sim/tools/linear/list_customers.ts
+++ b/apps/sim/tools/linear/list_customers.ts
@@ -1,5 +1,6 @@
import type { LinearListCustomersParams, LinearListCustomersResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomersTool: ToolConfig<
@@ -46,7 +47,7 @@ export const linearListCustomersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_cycles.ts b/apps/sim/tools/linear/list_cycles.ts
index 5a3ee3d078c..304b7cc38e2 100644
--- a/apps/sim/tools/linear/list_cycles.ts
+++ b/apps/sim/tools/linear/list_cycles.ts
@@ -1,5 +1,6 @@
import type { LinearListCyclesParams, LinearListCyclesResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCyclesTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListCyclesTool: ToolConfig {
diff --git a/apps/sim/tools/linear/list_favorites.ts b/apps/sim/tools/linear/list_favorites.ts
index 79e6cd0b01d..a778ef4bec0 100644
--- a/apps/sim/tools/linear/list_favorites.ts
+++ b/apps/sim/tools/linear/list_favorites.ts
@@ -1,4 +1,5 @@
import type { LinearListFavoritesParams, LinearListFavoritesResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListFavoritesTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearListFavoritesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_issue_relations.ts b/apps/sim/tools/linear/list_issue_relations.ts
index b008ba45fc4..23035e2b2dd 100644
--- a/apps/sim/tools/linear/list_issue_relations.ts
+++ b/apps/sim/tools/linear/list_issue_relations.ts
@@ -2,6 +2,7 @@ import type {
LinearListIssueRelationsParams,
LinearListIssueRelationsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListIssueRelationsTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListIssueRelationsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_labels.ts b/apps/sim/tools/linear/list_labels.ts
index ec1f66e375f..b4fceec910a 100644
--- a/apps/sim/tools/linear/list_labels.ts
+++ b/apps/sim/tools/linear/list_labels.ts
@@ -1,5 +1,6 @@
import type { LinearListLabelsParams, LinearListLabelsResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListLabelsTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListLabelsTool: ToolConfig {
diff --git a/apps/sim/tools/linear/list_notifications.ts b/apps/sim/tools/linear/list_notifications.ts
index 7509334c4fa..b09a1ae7bab 100644
--- a/apps/sim/tools/linear/list_notifications.ts
+++ b/apps/sim/tools/linear/list_notifications.ts
@@ -2,6 +2,7 @@ import type {
LinearListNotificationsParams,
LinearListNotificationsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListNotificationsTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearListNotificationsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_labels.ts b/apps/sim/tools/linear/list_project_labels.ts
index 5156572b817..c98dac175e4 100644
--- a/apps/sim/tools/linear/list_project_labels.ts
+++ b/apps/sim/tools/linear/list_project_labels.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectLabelsResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectLabelsTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListProjectLabelsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/list_project_milestones.ts b/apps/sim/tools/linear/list_project_milestones.ts
index 59e9bd2cb40..3039a407c43 100644
--- a/apps/sim/tools/linear/list_project_milestones.ts
+++ b/apps/sim/tools/linear/list_project_milestones.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectMilestonesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectMilestonesTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListProjectMilestonesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_statuses.ts b/apps/sim/tools/linear/list_project_statuses.ts
index b0b1efb1240..455f33500ca 100644
--- a/apps/sim/tools/linear/list_project_statuses.ts
+++ b/apps/sim/tools/linear/list_project_statuses.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectStatusesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectStatusesTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListProjectStatusesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_updates.ts b/apps/sim/tools/linear/list_project_updates.ts
index 2b76d92ab94..bf2a0a5a265 100644
--- a/apps/sim/tools/linear/list_project_updates.ts
+++ b/apps/sim/tools/linear/list_project_updates.ts
@@ -2,6 +2,7 @@ import type {
LinearListProjectUpdatesParams,
LinearListProjectUpdatesResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectUpdatesTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListProjectUpdatesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_projects.ts b/apps/sim/tools/linear/list_projects.ts
index 2af440f53a3..4ebbcb3e0b9 100644
--- a/apps/sim/tools/linear/list_projects.ts
+++ b/apps/sim/tools/linear/list_projects.ts
@@ -1,5 +1,6 @@
import type { LinearListProjectsParams, LinearListProjectsResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectsTool: ToolConfig<
@@ -52,7 +53,7 @@ export const linearListProjectsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/list_teams.ts b/apps/sim/tools/linear/list_teams.ts
index 268586ae7db..a695ff63f93 100644
--- a/apps/sim/tools/linear/list_teams.ts
+++ b/apps/sim/tools/linear/list_teams.ts
@@ -1,5 +1,6 @@
import type { LinearListTeamsParams, LinearListTeamsResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, TEAM_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListTeamsTool: ToolConfig = {
@@ -37,7 +38,7 @@ export const linearListTeamsTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_users.ts b/apps/sim/tools/linear/list_users.ts
index 00dace61086..f6378d39ea0 100644
--- a/apps/sim/tools/linear/list_users.ts
+++ b/apps/sim/tools/linear/list_users.ts
@@ -1,5 +1,6 @@
import type { LinearListUsersParams, LinearListUsersResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListUsersTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListUsersTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_workflow_states.ts b/apps/sim/tools/linear/list_workflow_states.ts
index cf95be8e1cc..87c0f36c210 100644
--- a/apps/sim/tools/linear/list_workflow_states.ts
+++ b/apps/sim/tools/linear/list_workflow_states.ts
@@ -3,6 +3,7 @@ import type {
LinearListWorkflowStatesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListWorkflowStatesTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListWorkflowStatesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/merge_customers.ts b/apps/sim/tools/linear/merge_customers.ts
index 6ed745d0687..d761df08c2c 100644
--- a/apps/sim/tools/linear/merge_customers.ts
+++ b/apps/sim/tools/linear/merge_customers.ts
@@ -1,4 +1,5 @@
import type { LinearMergeCustomersParams, LinearMergeCustomersResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearMergeCustomersTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearMergeCustomersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts
index 3dd443d12b7..fabe39c6af8 100644
--- a/apps/sim/tools/linear/read_issues.ts
+++ b/apps/sim/tools/linear/read_issues.ts
@@ -1,5 +1,6 @@
import type { LinearReadIssuesParams, LinearReadIssuesResponse } from '@/tools/linear/types'
import { ISSUE_LIST_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearReadIssuesTool: ToolConfig = {
@@ -97,7 +98,7 @@ export const linearReadIssuesTool: ToolConfig {
diff --git a/apps/sim/tools/linear/remove_label_from_issue.ts b/apps/sim/tools/linear/remove_label_from_issue.ts
index 262ce31905a..202d179dfae 100644
--- a/apps/sim/tools/linear/remove_label_from_issue.ts
+++ b/apps/sim/tools/linear/remove_label_from_issue.ts
@@ -2,6 +2,7 @@ import type {
LinearRemoveLabelFromIssueParams,
LinearRemoveLabelResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearRemoveLabelFromIssueTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearRemoveLabelFromIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/remove_label_from_project.ts b/apps/sim/tools/linear/remove_label_from_project.ts
index 278cfff393c..dd725782953 100644
--- a/apps/sim/tools/linear/remove_label_from_project.ts
+++ b/apps/sim/tools/linear/remove_label_from_project.ts
@@ -2,6 +2,7 @@ import type {
LinearRemoveLabelFromProjectParams,
LinearRemoveLabelFromProjectResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearRemoveLabelFromProjectTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearRemoveLabelFromProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/search_issues.ts b/apps/sim/tools/linear/search_issues.ts
index 05efaa59118..9bda838eece 100644
--- a/apps/sim/tools/linear/search_issues.ts
+++ b/apps/sim/tools/linear/search_issues.ts
@@ -1,5 +1,6 @@
import type { LinearSearchIssuesParams, LinearSearchIssuesResponse } from '@/tools/linear/types'
import { ISSUE_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearSearchIssuesTool: ToolConfig<
@@ -58,7 +59,7 @@ export const linearSearchIssuesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/unarchive_issue.ts b/apps/sim/tools/linear/unarchive_issue.ts
index 111b86d1e0f..bf46cbeee7d 100644
--- a/apps/sim/tools/linear/unarchive_issue.ts
+++ b/apps/sim/tools/linear/unarchive_issue.ts
@@ -1,4 +1,5 @@
import type { LinearUnarchiveIssueParams, LinearUnarchiveIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUnarchiveIssueTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearUnarchiveIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/update_attachment.ts b/apps/sim/tools/linear/update_attachment.ts
index 4afbe56bb41..f89d872048e 100644
--- a/apps/sim/tools/linear/update_attachment.ts
+++ b/apps/sim/tools/linear/update_attachment.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateAttachmentResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateAttachmentTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearUpdateAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_comment.ts b/apps/sim/tools/linear/update_comment.ts
index 67249d393ec..6102d5a27eb 100644
--- a/apps/sim/tools/linear/update_comment.ts
+++ b/apps/sim/tools/linear/update_comment.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateCommentParams, LinearUpdateCommentResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCommentTool: ToolConfig<
@@ -40,7 +41,7 @@ export const linearUpdateCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer.ts b/apps/sim/tools/linear/update_customer.ts
index e635d5f29c6..1e5f21f697b 100644
--- a/apps/sim/tools/linear/update_customer.ts
+++ b/apps/sim/tools/linear/update_customer.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateCustomerParams, LinearUpdateCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerTool: ToolConfig<
@@ -88,7 +89,7 @@ export const linearUpdateCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_request.ts b/apps/sim/tools/linear/update_customer_request.ts
index 317ddf315fe..ad380abf66d 100644
--- a/apps/sim/tools/linear/update_customer_request.ts
+++ b/apps/sim/tools/linear/update_customer_request.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateCustomerRequestParams,
LinearUpdateCustomerRequestResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerRequestTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateCustomerRequestTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_status.ts b/apps/sim/tools/linear/update_customer_status.ts
index 290ceedd339..7a30933330d 100644
--- a/apps/sim/tools/linear/update_customer_status.ts
+++ b/apps/sim/tools/linear/update_customer_status.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateCustomerStatusResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerStatusTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_tier.ts b/apps/sim/tools/linear/update_customer_tier.ts
index 62f5c2c9c3e..de72c7e501c 100644
--- a/apps/sim/tools/linear/update_customer_tier.ts
+++ b/apps/sim/tools/linear/update_customer_tier.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateCustomerTierParams,
LinearUpdateCustomerTierResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerTierTool: ToolConfig<
@@ -66,7 +67,7 @@ export const linearUpdateCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_issue.ts b/apps/sim/tools/linear/update_issue.ts
index 2b98eaa5c16..5f7053d712b 100644
--- a/apps/sim/tools/linear/update_issue.ts
+++ b/apps/sim/tools/linear/update_issue.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateIssueParams, LinearUpdateIssueResponse } from '@/tools/linear/types'
import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateIssueTool: ToolConfig =
@@ -110,7 +111,7 @@ export const linearUpdateIssueTool: ToolConfig {
diff --git a/apps/sim/tools/linear/update_label.ts b/apps/sim/tools/linear/update_label.ts
index ea34b08820f..d7edc5e9112 100644
--- a/apps/sim/tools/linear/update_label.ts
+++ b/apps/sim/tools/linear/update_label.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateLabelParams, LinearUpdateLabelResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateLabelTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearUpdateLabelTool: ToolConfig {
diff --git a/apps/sim/tools/linear/update_notification.ts b/apps/sim/tools/linear/update_notification.ts
index b297682141d..a298f2f0cc5 100644
--- a/apps/sim/tools/linear/update_notification.ts
+++ b/apps/sim/tools/linear/update_notification.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateNotificationParams,
LinearUpdateNotificationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateNotificationTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearUpdateNotificationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project.ts b/apps/sim/tools/linear/update_project.ts
index 10a65d3320b..31ca7a9529a 100644
--- a/apps/sim/tools/linear/update_project.ts
+++ b/apps/sim/tools/linear/update_project.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateProjectParams, LinearUpdateProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectTool: ToolConfig<
@@ -76,7 +77,7 @@ export const linearUpdateProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_label.ts b/apps/sim/tools/linear/update_project_label.ts
index 51932a54efe..804ffae7b6c 100644
--- a/apps/sim/tools/linear/update_project_label.ts
+++ b/apps/sim/tools/linear/update_project_label.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectLabelResponse,
} from '@/tools/linear/types'
import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectLabelTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearUpdateProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_milestone.ts b/apps/sim/tools/linear/update_project_milestone.ts
index 59a09b6c521..528f4754fd0 100644
--- a/apps/sim/tools/linear/update_project_milestone.ts
+++ b/apps/sim/tools/linear/update_project_milestone.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectMilestoneResponse,
} from '@/tools/linear/types'
import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectMilestoneTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearUpdateProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_status.ts b/apps/sim/tools/linear/update_project_status.ts
index c6688d91074..63d90185625 100644
--- a/apps/sim/tools/linear/update_project_status.ts
+++ b/apps/sim/tools/linear/update_project_status.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectStatusResponse,
} from '@/tools/linear/types'
import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectStatusTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_workflow_state.ts b/apps/sim/tools/linear/update_workflow_state.ts
index 536e2213aae..a27ffa87835 100644
--- a/apps/sim/tools/linear/update_workflow_state.ts
+++ b/apps/sim/tools/linear/update_workflow_state.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateWorkflowStateResponse,
} from '@/tools/linear/types'
import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateWorkflowStateTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearUpdateWorkflowStateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/utils.test.ts b/apps/sim/tools/linear/utils.test.ts
new file mode 100644
index 00000000000..18f8fc709de
--- /dev/null
+++ b/apps/sim/tools/linear/utils.test.ts
@@ -0,0 +1,16 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+describe('linearAuthorizationHeader', () => {
+ it('returns a personal API key bare without the Bearer scheme', () => {
+ expect(linearAuthorizationHeader('lin_api_abc123')).toBe('lin_api_abc123')
+ })
+
+ it('returns OAuth access tokens with the Bearer scheme', () => {
+ expect(linearAuthorizationHeader('lin_oauth_xyz789')).toBe('Bearer lin_oauth_xyz789')
+ expect(linearAuthorizationHeader('some-opaque-token')).toBe('Bearer some-opaque-token')
+ })
+})
diff --git a/apps/sim/tools/linear/utils.ts b/apps/sim/tools/linear/utils.ts
new file mode 100644
index 00000000000..6a07107b9f8
--- /dev/null
+++ b/apps/sim/tools/linear/utils.ts
@@ -0,0 +1,15 @@
+/**
+ * Builds the Authorization header value for Linear API requests.
+ *
+ * Linear documents two credential shapes: OAuth access tokens are sent as
+ * `Authorization: Bearer `, while personal API keys (prefixed with
+ * `lin_api_`) must be sent bare as `Authorization: ` with no scheme.
+ * This helper detects the personal-key prefix and returns the correct form
+ * so tools work with both OAuth connections and pasted API keys.
+ *
+ * @param accessToken - OAuth access token or Linear personal API key
+ * @returns The value to use for the `Authorization` header
+ */
+export function linearAuthorizationHeader(accessToken: string): string {
+ return accessToken.startsWith('lin_api_') ? accessToken : `Bearer ${accessToken}`
+}
diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts
index 04780af9aaf..950ca816452 100644
--- a/apps/sim/tools/shopify/adjust_inventory.ts
+++ b/apps/sim/tools/shopify/adjust_inventory.ts
@@ -48,7 +48,7 @@ export const shopifyAdjustInventoryTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts
index 97c9eb317c6..3a7ee6d7af9 100644
--- a/apps/sim/tools/shopify/cancel_order.ts
+++ b/apps/sim/tools/shopify/cancel_order.ts
@@ -64,7 +64,7 @@ export const shopifyCancelOrderTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts
index fac5bc17045..e56d1f2c48d 100644
--- a/apps/sim/tools/shopify/create_customer.ts
+++ b/apps/sim/tools/shopify/create_customer.ts
@@ -69,7 +69,7 @@ export const shopifyCreateCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts
index 6537fda0567..05435306d17 100644
--- a/apps/sim/tools/shopify/create_fulfillment.ts
+++ b/apps/sim/tools/shopify/create_fulfillment.ts
@@ -61,7 +61,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts
index bfee95f7c5f..dd1fb16c8d3 100644
--- a/apps/sim/tools/shopify/create_product.ts
+++ b/apps/sim/tools/shopify/create_product.ts
@@ -63,7 +63,7 @@ export const shopifyCreateProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts
index 06dfb072c4e..010cf109c83 100644
--- a/apps/sim/tools/shopify/delete_customer.ts
+++ b/apps/sim/tools/shopify/delete_customer.ts
@@ -32,7 +32,7 @@ export const shopifyDeleteCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts
index c00f46ab697..42b3aa843fa 100644
--- a/apps/sim/tools/shopify/delete_product.ts
+++ b/apps/sim/tools/shopify/delete_product.ts
@@ -32,7 +32,7 @@ export const shopifyDeleteProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts
index 480ea0ab07b..6cf7afa8dd6 100644
--- a/apps/sim/tools/shopify/get_collection.ts
+++ b/apps/sim/tools/shopify/get_collection.ts
@@ -40,7 +40,7 @@ export const shopifyGetCollectionTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts
index ef83449d198..e7657df42c7 100644
--- a/apps/sim/tools/shopify/get_customer.ts
+++ b/apps/sim/tools/shopify/get_customer.ts
@@ -31,7 +31,7 @@ export const shopifyGetCustomerTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts
index b39f9ca56f9..215bd731a36 100644
--- a/apps/sim/tools/shopify/get_inventory_level.ts
+++ b/apps/sim/tools/shopify/get_inventory_level.ts
@@ -42,7 +42,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts
index 93b552c6b3d..65f69ddaa02 100644
--- a/apps/sim/tools/shopify/get_order.ts
+++ b/apps/sim/tools/shopify/get_order.ts
@@ -30,7 +30,7 @@ export const shopifyGetOrderTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts
index afe9be4949f..e99c3b1140f 100644
--- a/apps/sim/tools/shopify/get_product.ts
+++ b/apps/sim/tools/shopify/get_product.ts
@@ -30,7 +30,7 @@ export const shopifyGetProductTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts
index e50ec507753..bc7bd3e2f0e 100644
--- a/apps/sim/tools/shopify/list_collections.ts
+++ b/apps/sim/tools/shopify/list_collections.ts
@@ -44,7 +44,7 @@ export const shopifyListCollectionsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts
index 09d160deeb1..0ecc1ab31e5 100644
--- a/apps/sim/tools/shopify/list_customers.ts
+++ b/apps/sim/tools/shopify/list_customers.ts
@@ -40,7 +40,7 @@ export const shopifyListCustomersTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts
index 97c3992a733..57de928cd96 100644
--- a/apps/sim/tools/shopify/list_inventory_items.ts
+++ b/apps/sim/tools/shopify/list_inventory_items.ts
@@ -44,7 +44,7 @@ export const shopifyListInventoryItemsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts
index 6d6e675f5be..f51817b23a3 100644
--- a/apps/sim/tools/shopify/list_locations.ts
+++ b/apps/sim/tools/shopify/list_locations.ts
@@ -40,7 +40,7 @@ export const shopifyListLocationsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts
index 1e78c4a7ae2..4d818bde393 100644
--- a/apps/sim/tools/shopify/list_orders.ts
+++ b/apps/sim/tools/shopify/list_orders.ts
@@ -43,7 +43,7 @@ export const shopifyListOrdersTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts
index 3903f9df073..ceb6cae7d23 100644
--- a/apps/sim/tools/shopify/list_products.ts
+++ b/apps/sim/tools/shopify/list_products.ts
@@ -40,7 +40,7 @@ export const shopifyListProductsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts
index fb877199fe3..d010c0b0cb9 100644
--- a/apps/sim/tools/shopify/types.ts
+++ b/apps/sim/tools/shopify/types.ts
@@ -725,7 +725,10 @@ interface ShopifyInventoryItem {
interface ShopifyBaseParams {
accessToken: string
shopDomain: string
- idToken?: string // Shop domain from OAuth, used as fallback
+ /** Store domain resolved from a service-account credential */
+ domain?: string
+ /** Shop domain from OAuth, used as fallback */
+ idToken?: string
}
// Product Tool Params
diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts
index e44b2ea7a93..af3ee478cbd 100644
--- a/apps/sim/tools/shopify/update_customer.ts
+++ b/apps/sim/tools/shopify/update_customer.ts
@@ -69,7 +69,7 @@ export const shopifyUpdateCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts
index 1d161722e1e..9e34c3476aa 100644
--- a/apps/sim/tools/shopify/update_order.ts
+++ b/apps/sim/tools/shopify/update_order.ts
@@ -48,7 +48,7 @@ export const shopifyUpdateOrderTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts
index caa2adbf930..771dca61343 100644
--- a/apps/sim/tools/shopify/update_product.ts
+++ b/apps/sim/tools/shopify/update_product.ts
@@ -69,7 +69,7 @@ export const shopifyUpdateProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {