feat(credentials): token-paste service accounts for 12 providers (#5682)

* feat(credentials): token-paste service accounts for 12 providers (HubSpot, Airtable, Notion, Asana, Attio, Linear, monday, Shopify, Webflow, Trello, Cal.com, Wealthbox)

* refactor(credentials): registry-dispatch service accounts + doc-verified validator hardening

- Migrate Google/Atlassian/Slack service-account branches to the same
  registry pattern as token-paste providers (builder + resolver maps,
  unified required-fields contract validation)
- Fix Shopify service-account store domain never reaching tool URL builders
- Linear: rate-limit 400s no longer mislabeled as invalid credentials;
  validation header matches runtime header rule
- Trello: server API key rejection no longer blamed on customer token
- Fleet-wide: network errors and non-JSON provider bodies map to
  provider_unavailable via shared fetchProvider/parseProviderJson
- HubSpot: drop regional-host displayName upgrade; Wealthbox: explicit 402;
  monday: provider-side GraphQL errors mapped correctly; Attio: null-body guard

* polish(credentials): ship-gate fixes, setup-guide docs, and lint pass for token service accounts

- 12 setup-guide docs pages (docs.sim.ai/integrations/<provider>-service-account)
  incl. the Trello authorize-link flow the integration depends on
- Attio: HTTP 400 on /v2/self maps to invalid_credentials (live-verified)
- monday: scan all GraphQL errors for provider-side codes; no empty audit ids
- Compile-time descriptor/validator lockstep (typed registry) and
  prototype-safe provider-id guard (Object.hasOwn)
- Shared errors.test.ts pinning fetchProvider/parseProviderJson/
  throwForProviderResponse guarantees for all validators
- Stale TSDoc updated after the registry migration; biome organizeImports pass

* docs(credentials): vendor-doc accuracy pass on service-account setup guides

34 corrections from a 12-agent audit against live official vendor docs:
current HubSpot Development-area nav, Notion connections rename, monday
Developer Center paths, Shopify legacy-vs-Dev-Dashboard token flows, and
hedged wording for claims vendors do not document (expiry, limits)

* improvement(credentials): vendor-accurate credential nouns on connect surfaces

'Add service account' only where the vendor actually has service accounts
(Google, Atlassian); token-paste providers now use their own vocabulary via a
connectNoun descriptor field — 'Add private app token' (HubSpot), 'Add API key'
(Attio/Linear/Cal.com), 'Add personal access token' (Airtable), etc. Docs page
titles updated to match; slugs and internal provider ids unchanged

* fix(credentials): classify auth-shaped Shopify GraphQL errors as invalid credentials

Shopify can reject invalid or revoked shpat_ tokens with HTTP 200 and a
GraphQL error body instead of a 401; those now map to invalid_credentials
instead of a provider-outage message

* fix(credentials): empirically-grounded HubSpot token verification

Live probing showed the documented access-token-info route returns a bare
404 for unrecognized tokens (ambiguous with a missing route), so 404/400
now falls back to the Account Information API, which answers with a JSON
401 for rejected tokens and 200/403 for live ones — verified against the
real endpoints

* fix(credentials): review-round-2 fixes for service-account edge cases

- Shopify tools prefer the credential-validated store domain over the
  block's auto-detected shopDomain (a store-bound token must hit its own store)
- Unknown non-empty service-account providerIds are rejected instead of
  silently persisting as google-service-account (empty stays the legacy
  Google fallback)
- Shopify/modal domain normalization strips URL paths ('https://x.myshopify.com/admin')
- monday: warning-class GraphQL errors no longer reject a token whose me
  data proves it authenticated

* chore(credentials): format shopify validator test

* fix(credentials): cold-review hardening pass

- Object.hasOwn guards on all provider-id registry lookups (crafted
  '__proto__'/'constructor' providerIds now 400 instead of 500) + regression test
- fetchProvider gets a 10s AbortSignal.timeout so a hung provider can't pin
  the create/reconnect request
- HubSpot: unexpected 403 on the token-info route defers to the account-info
  fallback instead of blaming the token
- Linear selector routes use the SDK apiKey option for lin_api_ keys (bare
  header parity with the tools sweep)
- Docs: UI steps aligned to the vendor-noun connect labels; HubSpot
  scope-propagation claims softened; Trello in-product-link promise corrected
This commit is contained in:
Waleed
2026-07-14 22:52:50 -07:00
committed by GitHub
parent 92549844c0
commit 00a2f26fe5
157 changed files with 5489 additions and 241 deletions
@@ -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
<Steps>
<Step>
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 */}
</Step>
<Step>
Give the token a name (e.g. `sim-airtable-bot`)
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
### 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.
<Callout type="info">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## 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.
<FAQ items={[
{ question: "Why a personal access token instead of OAuth?", answer: "PATs don't expire and don't depend on any user staying logged in or re-consenting. You decide scopes and base access once at creation, and the token is stable until you regenerate or delete it — which is what you want for an automated workflow." },
{ question: "What's the difference between a user PAT and a service-account PAT?", answer: "Nothing on the wire — both are the same token format and behave identically in Sim. The difference is ownership: a user PAT dies with the user's base access (or when they leave the org), while a service-account PAT (Enterprise Scale, created in the Admin Panel) belongs to the organization." },
{ question: "My token validates but every block fails with 403 or 404 — why?", answer: "The token is missing a scope or, more commonly, base access. Airtable deliberately blurs the two: an unauthorized base can return 403 INVALID_PERMISSIONS or a plain 404 NOT_FOUND. Edit the token at airtable.com/create/tokens and check both the scopes list and the Access section." },
{ question: "Do tokens expire?", answer: "No. Airtable PATs remain active indefinitely until they're manually deleted, regenerated, or modified. A user PAT also stops working if its creator loses access to the granted bases or leaves the organization." },
{ question: "How do I rotate the token?", answer: "Create a new token with the same scopes and base access, update the credential in Sim with the new token, and once it's working, delete the old one. Note that regenerating an existing token invalidates the old value immediately." },
{ question: "Do I need the webhook:manage scope?", answer: "Only if you use Airtable triggers in Sim — the trigger creates and manages webhooks on your base. For workflows that only read and write records, you can omit it." },
]} />
@@ -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
<Steps>
<Step>
Open the Asana **Admin Console** and go to **Apps** → **Service accounts**
{/* TODO(screenshot): Asana Admin Console with Apps → Service accounts highlighted */}
</Step>
<Step>
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
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## 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.
<Steps>
<Step>
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 */}
</Step>
<Step>
Click **+ Create new token**, give it a name that describes its use (e.g. `Sim Integration`), and click **Create token**
</Step>
<Step>
Copy the token immediately — Asana only shows it once.
</Step>
<Step>
Add the bot account to the workspaces and projects your workflows need. A PAT can only see what its user can see.
</Step>
</Steps>
## Adding the Token to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="info">
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.
</Callout>
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.
<FAQ items={[
{ question: "What's the difference between a service account token and a personal access token?", answer: "A service account is an org-level bot identity with its own name and (with Full Permissions) org-wide access — it isn't tied to any person. A PAT is tied to the user who created it, inherits only their permissions, and breaks if that user is deactivated. Both use the same token format and paste into the same field in Sim; prefer the service account for team use if your plan supports it." },
{ question: "Why does my scoped service account fail to connect?", answer: "Scoped service accounts (e.g. limited to User Provisioning / SCIM) can't call the standard Asana API, so Sim's validation call fails. Ask your super admin to create the service account with Full Permissions." },
{ question: "Does the token expire?", answer: "No scheduled expiry — the token is long-lived until a super admin revokes or regenerates it (for a PAT, until the user deletes it or is deprovisioned). If a working credential suddenly starts failing with 401 errors, the token was revoked; paste a new one." },
{ question: "How do I rotate the token?", answer: "Regenerate the service account token in the Admin Console, then update the credential in Sim with the new token right away — the old token stops working once it's replaced, so expect a brief window of failed runs if workflows execute in between." },
{ question: "Do I need to enter a workspace or domain?", answer: "No. Asana has a single API host, and Sim's Asana blocks take workspace and project IDs as block parameters — the token is all the credential needs." },
{ question: "Will one shared token hit rate limits?", answer: "Asana rate-limits per token, so all workflows sharing one service account token draw from a single rate-limit bucket. If you run heavy concurrent workloads, watch for 429 responses and consider splitting workloads across tokens." },
]} />
@@ -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
<Steps>
<Step>
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 */}
</Step>
<Step>
Click **New access token** and give it a name (e.g. `Sim Integration`)
</Step>
<Step>
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 */}
</Step>
<Step>
Copy the key and store it somewhere safe.
</Step>
</Steps>
<Callout type="info">
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.
</Callout>
<Callout type="warn">
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.
</Callout>
## Adding the API Key to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
The key is encrypted before being stored.
<Callout type="warn">
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.
</Callout>
## 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.
<FAQ items={[
{ question: "Does the API key expire?", answer: "No — Attio access tokens do not expire. They stay valid until a workspace admin deletes them, which revokes access immediately." },
{ question: "Can a regular user create an API key?", answer: "No. Only workspace admins can create and manage access tokens. Ask a workspace admin to create the key for you." },
{ question: "My key connected fine but a block fails with a permission error — why?", answer: "The key is missing a scope. Validation only checks that the key is alive, not which scopes it carries. Have an admin open the key in Workspace settings → Developers and add the missing scope — scopes are editable after creation, no rotation needed." },
{ question: "Do I need to enter a workspace domain?", answer: "No. Attio has a single API host and the key itself identifies the workspace — the key is all the credential needs." },
{ question: "How do I rotate the key?", answer: "Create a new access token with the same scopes in Attio, update the credential in Sim with the new key, and once it's working, delete the old token." },
{ question: "Is the key tied to the admin who created it?", answer: "The key belongs to the workspace, not the creating admin's user account — it keeps working regardless of who created it, until it's deleted." },
]} />
@@ -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.
<Callout type="info">
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.
</Callout>
## Creating the API Key
<Steps>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
Live-mode keys start with `cal_live_`; test-mode keys start with `cal_`. Either form is accepted.
<Callout type="warn">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why an API key instead of OAuth?", answer: "API keys don't require any user to sit through a consent flow, and they stay valid until they expire or are revoked — which is what you want for an automated workflow. OAuth connections are tied to one person's session and consent." },
{ question: "Do Cal.com API keys have scopes?", answer: "No — a key carries the full permissions of the user who created it. There's nothing to select at creation time, and no Cal.com block in Sim can fail for missing scopes. Permission errors only arise from resource ownership, e.g. confirming a booking the key's user doesn't host." },
{ question: "What happens when the key expires?", answer: "Workflows using the credential start failing with authentication errors. Create a new key from the same Cal.com account, update the credential in Sim, and delete the expired key. If you couldn't create a non-expiring key, set a reminder before the expiry date you chose." },
{ question: "Can I use a test-mode (cal_) key?", answer: "It will validate, but test-mode keys may not behave like live keys on booking operations. Use a cal_live_ key for production workflows." },
{ question: "Does this work with self-hosted Cal.com?", answer: "No — Sim's Cal.com blocks call the cal.com cloud API (api.cal.com). Self-hosted Cal.com instances expose their own API host and are not supported by this credential." },
{ question: "How do I rotate the key?", answer: "Create a new key from Settings → Developer → API keys, update the credential in Sim with the new key, and once it's working, delete the old one in Cal.com." },
]} />
@@ -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.
<Callout type="warn">
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.
</Callout>
## Setting Up the Private App
### 1. Create the Private App
<Steps>
<Step>
In HubSpot, navigate to **Development** in the left sidebar, then **Legacy apps**
{/* TODO(screenshot): HubSpot navigation showing Development → Legacy apps */}
<Callout type="info">
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.
</Callout>
</Step>
<Step>
Click **Create legacy app** in the top right, select **Private**, and give it a name (e.g. `sim-hubspot-bot`) and description
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
</Steps>
### 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.
<Callout type="info">
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.
</Callout>
### 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.
<Callout type="warn">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
## 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.
<FAQ items={[
{ question: "Why a private app token instead of OAuth?", answer: "Private app tokens don't expire on their own and don't depend on any user staying logged in or re-consenting. A super admin issues them with explicit scopes, and they're stable until rotated — which is what you want for an automated workflow." },
{ question: "Can a regular user create a private app?", answer: "No. Only super admins can create and manage private apps in a HubSpot portal." },
{ question: "My token validates but a block fails with a 403 — why?", answer: "The private app is missing a scope that tool needs. The error names the required scopes (category MISSING_SCOPES). Add them on the app's Scopes tab in HubSpot, commit the change, and re-run the workflow. If the 403 persists, rotate the token and update the credential in Sim." },
{ question: "Does it matter that my token starts with pat-eu1- instead of pat-na1-?", answer: "No. The prefix reflects your portal's data residency region. Sim handles both — all requests go to api.hubapi.com, which serves both regions." },
{ question: "What happens if the super admin who created the app leaves?", answer: "The token keeps validating, but calls that check user permissions can start failing with USER_DOES_NOT_HAVE_PERMISSIONS — association calls are commonly reported. Have another super admin rotate the token (or recreate the app) and update the credential in Sim." },
{ question: "How do I rotate the token?", answer: "In the private app's Auth tab, choose Rotate and expire later (7-day grace), paste the new token into the credential in Sim, verify your workflows, and let the old token expire. Use Rotate and expire now only if the token may be compromised." },
]} />
@@ -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
<Steps>
<Step>
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 */}
</Step>
<Step>
Click **New API key** and give it a label (e.g. `Sim Integration`)
</Step>
<Step>
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 */}
</Step>
<Step>
Copy the key — it starts with `lin_api_` — and store it somewhere safe.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## Adding the API Key to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
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.
<FAQ items={[
{ question: "Does Linear have real service accounts?", answer: "Not for API keys — every key is tied to a user. The closest equivalent is creating a dedicated service user in your workspace and generating the key from that account, so workflows aren't attributed to (or broken by) a real person's account." },
{ question: "Does the key expire?", answer: "No — keys are long-lived and don't auto-rotate. They stay valid until deleted, or until the user who created them is deactivated or removed from the workspace." },
{ question: "What happens if the key's user leaves the company?", answer: "Keys are user-bound, so deactivating the user kills the key and every workflow using it starts failing with authentication errors. This is exactly why production keys should come from a dedicated service user." },
{ question: "My key connected fine but write blocks fail with permission errors — why?", answer: "The key was created with restricted permissions — for example, Read only, or limited to specific teams. Validation checks that the key is alive and can read its own user, but write failures surface at run time. Create a new key with Read + Write and no team restriction, then update the credential in Sim." },
{ question: "Do I need to enter a workspace domain?", answer: "No. Linear has a single API host and the key itself identifies the workspace — the key is all the credential needs." },
{ question: "How do I rotate the key?", answer: "Create a new key from the same service user in Linear, update the credential in Sim with the new key, and once it's working, delete the old one." },
]} />
@@ -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",
@@ -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.
<Callout type="warn">
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.
</Callout>
## Setting Up the Token
### 1. Create a Dedicated Integration User (Recommended)
<Steps>
<Step>
Have a monday.com admin invite a new member with a shared email like `sim-bot@yourcompany.com` and confirm the email address
</Step>
<Step>
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
</Step>
</Steps>
### 2. Generate the Personal API Token
<Steps>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
Copy the token exactly as shown — monday.com doesn't document a fixed token format
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
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
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why a personal API token instead of OAuth?", answer: "Personal tokens don't expire on a schedule and don't depend on any user's OAuth consent staying valid. Generated from a dedicated integration user, they behave like a service account credential — stable until you deliberately regenerate or deactivate them." },
{ question: "Do I need to pick scopes for the token?", answer: "No. Personal API tokens carry all permission scopes automatically. Access is limited only by what the token's user can see and do in monday.com — board membership is your permission model." },
{ question: "Why do you recommend a dedicated integration user?", answer: "The token is bound to a user, and monday.com has no org-level token. If the token comes from an employee's personal account, it dies when that person is deactivated during offboarding. A dedicated seat like sim-bot@yourcompany.com survives team changes and makes the bot's activity auditable." },
{ question: "How do I rotate the token?", answer: "Carefully. monday.com allows only one token per user, and regenerating immediately kills the old one — there's no dual-token window. Regenerate in monday.com, then update the credential in Sim right away. Expect a brief outage for any workflows that run in between." },
{ question: "Why do my workflows fail on some boards but not others?", answer: "The token mirrors its user's board access. Add the integration user to the board in monday.com (with edit rights if the workflow writes), and the failing block will start working — no token change needed." },
{ question: "Does the token expire?", answer: "monday.com doesn't expire personal tokens on a schedule — they last until regenerated or until the user is deactivated. There's no official non-expiry guarantee, so treat a sudden 401 as a signal the token was regenerated or the user was deactivated." },
]} />
@@ -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
<Steps>
<Step>
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 */}
</Step>
<Step>
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**)
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
### 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.
<Steps>
<Step>
Open a page or database your workflows need in Notion
</Step>
<Step>
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 */}
</Step>
<Step>
Repeat for each top-level page or database. Connecting a page also grants access to all of its sub-pages.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
## 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.
<FAQ items={[
{ question: "Why an internal integration instead of OAuth?", answer: "The integration is owned by the workspace, not a person. No user has to stay logged in or re-consent, the secret has no documented expiry, and access survives employee offboarding. Capabilities and page connections are explicit and auditable." },
{ question: "Can a regular member create an internal integration?", answer: "No. Only workspace owners can access the integrations settings and create internal integrations." },
{ question: "My secret validates but blocks return 404 object_not_found — why?", answer: "The page or database isn't connected to the integration. Validation only proves the secret is real; it says nothing about page access. Open the page in Notion, use the ••• menu → Connections, and add your integration. Connecting a page covers its sub-pages too." },
{ question: "My secret starts with secret_ instead of ntn_ — is that a problem?", answer: "No. Notion switched new secrets to the ntn_ prefix in September 2024, but existing secret_ tokens remain valid. Sim accepts both." },
{ question: "A block fails with 403 restricted_resource — what does that mean?", answer: "The secret is valid but the integration is missing a capability that tool needs — for example, calling a write tool with only Read content enabled, or listing users without a user capability. Enable the capability on the integration's settings page in Notion." },
{ question: "How do I rotate the secret?", answer: "The integration's Configuration tab has a refresh/regenerate action for the secret. Regenerate it, then update the credential in Sim with the new value. Plan for the old secret to stop working as soon as you regenerate." },
{ question: "Does the integration see everything its members can see?", answer: "No — the reverse. It sees only pages explicitly connected to it, and its capabilities can never exceed the permissions of the user interacting through it." },
]} />
@@ -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.
<Callout type="info">
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.
</Callout>
## Setting Up the Custom App
### 1. Create the App and Grant Scopes
<Steps>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
Install the app on your store, then obtain the `shpat_` token as described in the next section — the flow differs by app type
</Step>
</Steps>
### 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 */}
<Callout type="warn">
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).
</Callout>
### 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
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why an Admin API token instead of OAuth?", answer: "The custom app token is permanent, store-bound, and carries exactly the scopes you configured — no user consent to expire and no refresh cycle. That's what you want for an automated workflow." },
{ question: "Does the token expire?", answer: "No. Admin API access tokens from custom apps don't expire. They stop working only if the app is uninstalled or deleted. (Tokens from the Dev Dashboard's client-credentials flow are different — those expire in 24 hours and are not what Sim stores.)" },
{ question: "I lost the token — can I see it again?", answer: "No. Shopify reveals the token only once at creation. Reinstall the app (or recreate it) to generate a new token, then update the credential in Sim. Note the old token is invalidated when you do." },
{ question: "Can one token work for multiple stores?", answer: "No. Custom apps are single-store — the token works for exactly the store it was installed on. If you run multiple stores, create a custom app on each and add each as a separate credential in Sim." },
{ question: "What happens if the app is missing a scope a workflow needs?", answer: "The block fails at run time with an access-denied error naming the operation. Update the app's Admin API scopes and re-authorize/reinstall the app. Note that reinstalling generates new credentials, so update the token stored in Sim afterwards." },
{ question: "How do I rotate the token?", answer: "Admin-created custom apps can't rotate credentials in place — recovery is uninstall-and-reinstall or delete-and-recreate, both of which invalidate the old token immediately. Do it in a maintenance window and update the Sim credential right after." },
]} />
@@ -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.
<Callout type="warn">
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.
</Callout>
## 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=<SIM_TRELLO_API_KEY>
```
<Callout type="info">
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.
</Callout>
<Steps>
<Step>
Log in to Trello as the account that should own the token (the bot account, for production)
</Step>
<Step>
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 */}
</Step>
<Step>
Click **Allow**. Trello displays the token on-screen — copy it
{/* TODO(screenshot): Trello token display page after clicking Allow, with the token string shown */}
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
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
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why does the token have to be minted with Sim's API key?", answer: "Trello binds every token to the API key that authorized it. At runtime, Sim pairs your token with its own API key on every request — a token minted under any other key gets rejected with 401 'invalid token'. This is a Trello platform rule, not a Sim limitation." },
{ question: "I generated a token from my own Power-Up's key and validation fails. What now?", answer: "Discard that token and mint a new one using the authorize URL with the Sim deployment's key — ask Sim support for the ready-made link on hosted cloud, or use your own TRELLO_API_KEY value if self-hosting. Tokens are free to create. (If your account is managed by a Trello Enterprise, admins can restrict token creation — ask your Enterprise admin if the authorize page won't issue a token.)" },
{ question: "Is the Trello API key a secret?", answer: "No — Trello API keys are public identifiers by design (they appear in client-side authorize URLs). The token is the secret. Treat the token like a password; the key can be shared freely." },
{ question: "Does Trello have real service accounts?", answer: "No — Trello tokens are always bound to a Trello member (a user). The closest equivalent is a dedicated bot account: create a separate Trello user for automation, invite it to the boards your workflows need, and mint the token as that user." },
{ question: "What happens if the board access changes?", answer: "The token always reflects the live permissions of the member who minted it. If the bot account is removed from a board, workflows touching that board start failing with 401/404 errors — re-invite the account to fix it, no new token needed." },
{ question: "How do I revoke or rotate the token?", answer: "Log in as the token's owner, open your Trello account settings (trello.com/u/my/account), scroll to the Applications section, and click Revoke next to 'Sim'. To rotate, mint a fresh token from the same authorize URL and update the credential in Sim before revoking the old one." },
]} />
@@ -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.
<Callout type="info">
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.
</Callout>
## Creating the API Access Token
<Steps>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
Copy the token. Wealthbox doesn't document an expiry for API access tokens; you can revoke it from this same page at any time.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why an API access token instead of OAuth?", answer: "API access tokens are long-lived and don't require any user to sit through a consent flow — they stay valid until you revoke them, which is what you want for an automated workflow. OAuth connections are tied to one person's session." },
{ question: "Do Wealthbox tokens have scopes?", answer: "No — a token carries the full permissions of the user who created it. Authorization in Wealthbox is scoped to individual users, not organizations. If a workflow gets a 403 on a specific record, the token's user lacks visibility to that record — adjust the user's permissions in Wealthbox." },
{ question: "I don't see an API Access section in my settings. Why?", answer: "Trial accounts are blocked from the Wealthbox API, and some accounts may not show the section. Contact Wealthbox support if API Access is missing from your Settings." },
{ question: "Do tokens expire?", answer: "Wealthbox doesn't document an expiry for API access tokens — expect them to work until revoked from the API Access settings page. If a workflow suddenly fails with a 401, the most likely cause is that someone revoked the token." },
{ question: "My user belongs to multiple Wealthbox workspaces. Which one does the token use?", answer: "Wealthbox doesn't document workspace selection for API tokens. The API reports the token user's current workspace in /v1/me (current_user.account) — if your data spans multiple workspaces, test which workspace the token reads before relying on it." },
{ question: "How do I rotate the token?", answer: "There's no rotation API — create a new token from the API Access settings page, update the credential in Sim with the new token, and once it's working, revoke the old one." },
]} />
@@ -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
<Steps>
<Step>
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 */}
</Step>
<Step>
Click **Generate API token** and give it a recognizable name (e.g. `sim-workflows`)
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
<Callout type="warn">
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.
</Callout>
## Adding the Service Account to Sim
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
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 */}
</Step>
<Step>
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 */}
</Step>
<Step>
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.
</Step>
</Steps>
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.
<FAQ items={[
{ question: "Why a site token instead of OAuth?", answer: "Site tokens aren't tied to any user account and don't depend on OAuth consent staying valid. They're generated by a site admin, scoped explicitly, and stay valid as long as they're used — which suits automated workflows." },
{ question: "Can one token cover multiple sites?", answer: "No. A site token is bound to exactly one site. If your workflows touch several Webflow sites, generate a token on each site and add each as a separate credential in Sim. For many sites, Webflow's OAuth flow may be more practical." },
{ question: "Why did adding the token to Sim fail even though my scopes looked right?", answer: "The most common cause is a missing Sites: Read scope — Sim verifies the token by listing its accessible sites, so a CMS-only token fails validation even though the CMS tools themselves would work. Generate a new token that includes Sites: Read." },
{ question: "Does the token expire?", answer: "Only from inactivity: 365 consecutive days without any API call. Regular use resets the clock. A dormant credential dies silently, so run rarely-used workflows at least once a year or set a reminder." },
{ question: "How do I rotate the token?", answer: "Generate a new token from the site's API access settings (watch the 5-token-per-site cap), update the credential in Sim, and once it's working, revoke the old one." },
{ question: "What if a workflow needs scopes the token doesn't have?", answer: "Scopes are fixed at creation — generate a new token with the wider scope set, update the credential in Sim, and revoke the old token. Scope failures show up as 401 authorization errors on the failing block." },
]} />
+80 -22
View File
@@ -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,24 +325,83 @@ 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
}
/**
* 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.
*/
async function getTokenServiceAccountSecret(
credentialId: string,
providerId: string
): Promise<TokenServiceAccountSecretBlob> {
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
}
type ServiceAccountTokenResolver = (
credentialId: string,
options: ServiceAccountTokenOptions
) => Promise<ServiceAccountTokenResult>
/**
* 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<string, ServiceAccountTokenResolver> = {
[ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
},
[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 }
},
[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) }
},
}
/**
* 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.
* provider is one registry entry and an unknown provider fails loudly instead
* of silently attempting a Google JWT.
*/
export async function resolveServiceAccountToken(
credentialId: string,
@@ -345,24 +409,18 @@ export async function resolveServiceAccountToken(
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult> {
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
if (providerId && isTokenServiceAccountProviderId(providerId)) {
const secret = await getTokenServiceAccountSecret(credentialId, providerId)
return { accessToken: secret.apiToken, domain: secret.domain }
}
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
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'}`)
}
if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
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'}`)
return resolver(credentialId, { scopes, impersonateEmail })
}
/**
+9
View File
@@ -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(
{
@@ -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
+5 -1
View File
@@ -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,
@@ -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,
},
]
@@ -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 (
<TokenServiceAccountModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
descriptor={tokenDescriptor}
serviceName={serviceName}
serviceIcon={serviceIcon}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
return (
<ConnectSlackBotModal
@@ -0,0 +1,245 @@
'use client'
import { type ComponentType, useEffect, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
SecretInput,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
import type {
TokenServiceAccountDescriptor,
TokenServiceAccountField,
} from '@/lib/credentials/token-service-accounts/descriptors'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
const logger = createLogger('TokenServiceAccountModal')
const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment."
/**
* Maps server `error.code` values from token service-account verification to
* user-facing messages, personalized with the provider's own token noun.
*/
function messageForTokenAccountError(
err: unknown,
descriptor: TokenServiceAccountDescriptor
): string {
if (isApiClientError(err) && err.code) {
switch (err.code) {
case 'invalid_credentials':
return `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.`
case 'site_not_found':
return "We couldn't find an account at that domain. Check the spelling and try again."
case 'provider_unavailable':
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
case 'duplicate_display_name':
return 'A credential with that name already exists in this workspace.'
default:
return FALLBACK_ERROR_MESSAGE
}
}
return FALLBACK_ERROR_MESSAGE
}
function normalizeDomainInput(raw: string): string {
return raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/[/?#].*$/, '')
}
function openDocs(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer')
}
interface TokenServiceAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => 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<string | null>(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 (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle={`Add ${serviceName} ${descriptor.connectNoun}`}
>
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
Add {serviceName} {descriptor.connectNoun}
</ChipModalHeader>
<ChipModalBody>
{tokenField && (
<ChipModalField
type='custom'
title={tokenField.label}
required
hint={hintFor(tokenField, trimmedToken) ?? descriptor.helpText}
>
<SecretInput
value={apiToken}
onChange={(value) => {
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'
/>
</ChipModalField>
)}
{domainField && (
<ChipModalField
type='input'
title={domainField.label}
value={domain}
onChange={(value) => {
setDomain(value)
if (error) setError(null)
}}
placeholder={domainField.placeholder}
autoComplete='off'
required
error={hintFor(domainField, normalizedDomain)}
/>
)}
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={setDisplayName}
placeholder={`Defaults to the ${descriptor.serviceLabel} account name`}
autoComplete='off'
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
label: 'Setup guide',
onClick: () => openDocs(descriptor.docsUrl),
},
]}
primaryAction={{
label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`,
onClick: handleSubmit,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
+6 -41
View File
@@ -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
}
@@ -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
}
}
@@ -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<string, readonly ServiceAccountFieldId[]> = {
[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
}
@@ -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')
}
})
})
@@ -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<ServiceAccountSecretResult> {
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'
)
}
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,
}
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,
},
}
}
/**
* 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<ServiceAccountSecretResult> {
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<ServiceAccountSecretResult> {
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<ServiceAccountSecretResult> {
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<ServiceAccountSecretResult>
/**
* 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<string, ServiceAccountSecretBuilder> = {
[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<ServiceAccountSecretResult> {
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}`)
}
@@ -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` (`<provider>-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<string, TokenServiceAccountFieldId[]> =
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
}
@@ -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<unknown>
): Promise<TokenServiceAccountValidationError> {
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('<html>502 Bad Gateway</html>', { 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('')
})
})
})
@@ -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<string, unknown>
) {
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<Response> {
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<T>(res: Response, step: string): Promise<T> {
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<string> {
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<string, unknown> = {}
): Promise<void> {
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,
})
}
@@ -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<string, string>
/**
* Non-secret metadata persisted inside the encrypted blob alongside the
* token (e.g. normalized store domain, portal id) for later debugging.
*/
storedMetadata?: Record<string, string>
/** Normalized domain to persist instead of the raw user input (when collected). */
normalizedDomain?: string
}
export type TokenServiceAccountValidator = (
fields: TokenServiceAccountFields
) => Promise<TokenServiceAccountValidationResult>
/**
* 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<string, string>
}
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
}
@@ -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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<string, string> = { userId: whoami.id }
if (whoami.scopes) {
storedMetadata.scopes = whoami.scopes.join(' ')
}
return {
displayName: whoami.email ?? `Airtable user ${whoami.id}`,
auditMetadata: { airtableUserId: whoami.id },
storedMetadata,
}
}
@@ -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('<html>gateway</html>', { 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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<AsanaMeResponse>(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<string, string> = { userGid: gid }
if (email) storedMetadata.email = email
return {
displayName: name || email || `Asana user ${gid}`,
auditMetadata: { asanaUserGid: gid },
storedMetadata,
}
}
@@ -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<unknown>,
code: string
): Promise<TokenServiceAccountValidationError> {
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'
)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<AttioSelfResponse | null>(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<string, string> = { 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,
}
}
@@ -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('<html>gateway timeout</html>', {
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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
const res = await fetchProvider(
CALCOM_ME_URL,
{
headers: {
Authorization: `Bearer ${fields.apiToken}`,
Accept: 'application/json',
},
},
'me'
)
await throwForProviderResponse(res, 'me')
const body = await parseProviderJson<CalcomMeResponse>(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<string, string> = { userId }
if (email) storedMetadata.email = email
return {
displayName: username || email || 'Cal.com account',
auditMetadata: { calcomUserId: userId },
storedMetadata,
}
}
@@ -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, '<html><body>404 Not Found</body></html>'))
.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, '<html><body>404 Not Found</body></html>'))
.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,
})
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<HubspotAccountInfo>(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<TokenServiceAccountValidationResult> {
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<HubspotTokenInfo>(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<string, string> = { 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,
}
}
@@ -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<string, string>
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('<html>proxy error</html>', {
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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<LinearViewerResponse>(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<string, string> = { viewerId: viewer.id }
const auditMetadata: Record<string, string> = {}
if (organization?.id) {
storedMetadata.organizationId = organization.id
auditMetadata.linearOrganizationId = organization.id
}
return {
displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace',
auditMetadata,
storedMetadata,
}
}
@@ -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')
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<MondayValidationBody>(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<string, string> = { accountId, userId }
if (account?.slug) {
storedMetadata.accountSlug = account.slug
}
const auditMetadata: Record<string, string> = {}
if (accountId) {
auditMetadata.mondayAccountId = accountId
}
return {
displayName: account?.name || me.name || me.email || `monday user ${userId}`,
auditMetadata,
storedMetadata,
}
}
@@ -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('<html>gateway</html>', { 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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<NotionBotUser>(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<string, string> = { botId: me.id }
if (workspaceName) {
storedMetadata.workspaceName = workspaceName
}
return {
displayName: me.name || workspaceName || 'Notion integration',
auditMetadata: { notionBotId: me.id },
storedMetadata,
}
}
@@ -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('<html>gateway error</html>', {
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')
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<ShopifyShopResponse>(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<string, string> = { shopDomain: canonicalDomain }
if (shopName) storedMetadata.shopName = shopName
return {
displayName: shopName ?? canonicalDomain,
auditMetadata: { shopifyShopDomain: canonicalDomain },
storedMetadata,
normalizedDomain: canonicalDomain,
}
}
@@ -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 () => '<html>proxy error</html>',
} 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,
})
})
})
@@ -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<TokenServiceAccountValidationResult> {
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<TrelloMember>(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<string, string> = { 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,
}
}
@@ -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<string, string>
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)
})
})
@@ -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<boolean> {
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<TokenServiceAccountValidationResult> {
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<WealthboxMeResponse>(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<string, string> = {}
if (userId) auditMetadata.wealthboxUserId = userId
const storedMetadata: Record<string, string> = {}
if (userId) storedMetadata.userId = userId
if (email) storedMetadata.email = email
return { displayName, auditMetadata, storedMetadata }
}
@@ -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('<html>gateway timeout</html>', {
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)
})
})
@@ -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<TokenServiceAccountValidationResult> {
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 },
}
}
+3 -1
View File
@@ -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
}
+12
View File
@@ -589,6 +589,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
name: 'HubSpot',
description: 'Access and manage your HubSpot CRM data.',
providerId: 'hubspot',
serviceAccountProviderId: 'hubspot-service-account',
icon: HubspotIcon,
baseProviderIcon: HubspotIcon,
scopes: [
+2 -1
View File
@@ -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) => ({
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => {
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
+2 -1
View File
@@ -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<LinearCreateCycleParams, LinearCreateCycleResponse> =
@@ -50,7 +51,7 @@ export const linearCreateCycleTool: ToolConfig<LinearCreateCycleParams, LinearCr
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
+2 -1
View File
@@ -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) => {
+2 -1
View File
@@ -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<LinearCreateIssueParams, LinearCreateIssueResponse> =
@@ -110,7 +111,7 @@ export const linearCreateIssueTool: ToolConfig<LinearCreateIssueParams, LinearCr
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
@@ -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) => ({
+2 -1
View File
@@ -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<LinearCreateLabelParams, LinearCreateLabelResponse> =
@@ -50,7 +51,7 @@ export const linearCreateLabelTool: ToolConfig<LinearCreateLabelParams, LinearCr
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
+2 -1
View File
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
@@ -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) => ({
@@ -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) => ({
+2 -1
View File
@@ -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<LinearDeleteIssueParams, LinearDeleteIssueResponse> =
@@ -31,7 +32,7 @@ export const linearDeleteIssueTool: ToolConfig<LinearDeleteIssueParams, LinearDe
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
@@ -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) => ({
@@ -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) => ({
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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<LinearGetCustomerParams, LinearGetCustomerResponse> =
@@ -32,7 +33,7 @@ export const linearGetCustomerTool: ToolConfig<LinearGetCustomerParams, LinearGe
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
+2 -1
View File
@@ -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<LinearGetCycleParams, LinearGetCycleResponse> = {
@@ -31,7 +32,7 @@ export const linearGetCycleTool: ToolConfig<LinearGetCycleParams, LinearGetCycle
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
+2 -1
View File
@@ -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<LinearGetIssueParams, LinearGetIssueResponse> = {
@@ -31,7 +32,7 @@ export const linearGetIssueTool: ToolConfig<LinearGetIssueParams, LinearGetIssue
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
+2 -1
View File
@@ -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<LinearGetProjectParams, LinearGetProjectResponse> = {
@@ -31,7 +32,7 @@ export const linearGetProjectTool: ToolConfig<LinearGetProjectParams, LinearGetP
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
+2 -1
View File
@@ -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<LinearGetViewerParams, LinearGetViewerResponse> = {
@@ -24,7 +25,7 @@ export const linearGetViewerTool: ToolConfig<LinearGetViewerParams, LinearGetVie
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: () => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
@@ -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) => ({
@@ -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) => ({
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) => ({

Some files were not shown because too many files have changed in this diff Show More