From 4bba8b4609ea0955430aab81d17ade695342f1ae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 17:17:45 -0700 Subject: [PATCH 01/10] feat(credentials): token-paste service accounts for 12 providers (HubSpot, Airtable, Notion, Asana, Attio, Linear, monday, Shopify, Webflow, Trello, Cal.com, Wealthbox) --- apps/sim/app/api/auth/oauth/utils.ts | 32 ++ apps/sim/app/api/credentials/route.ts | 9 + .../connect-service-account-modal.tsx | 22 ++ .../token-service-account-modal.tsx | 245 ++++++++++++++ apps/sim/lib/api/contracts/credentials.ts | 14 + .../lib/credentials/orchestration/index.ts | 9 + .../lib/credentials/service-account-secret.ts | 42 +++ .../token-service-accounts/descriptors.ts | 313 ++++++++++++++++++ .../token-service-accounts/errors.ts | 63 ++++ .../token-service-accounts/server.ts | 106 ++++++ .../validators/airtable.test.ts | 76 +++++ .../validators/airtable.ts | 54 +++ .../validators/asana.test.ts | 96 ++++++ .../validators/asana.ts | 56 ++++ .../validators/attio.test.ts | 102 ++++++ .../validators/attio.ts | 69 ++++ .../validators/calcom.test.ts | 79 +++++ .../validators/calcom.ts | 59 ++++ .../validators/hubspot.test.ts | 106 ++++++ .../validators/hubspot.ts | 92 +++++ .../validators/linear.test.ts | 93 ++++++ .../validators/linear.ts | 114 +++++++ .../validators/monday.test.ts | 98 ++++++ .../validators/monday.ts | 97 ++++++ .../validators/notion.test.ts | 96 ++++++ .../validators/notion.ts | 63 ++++ .../validators/shopify.test.ts | 114 +++++++ .../validators/shopify.ts | 117 +++++++ .../validators/trello.test.ts | 103 ++++++ .../validators/trello.ts | 73 ++++ .../validators/wealthbox.test.ts | 97 ++++++ .../validators/wealthbox.ts | 97 ++++++ .../validators/webflow.test.ts | 89 +++++ .../validators/webflow.ts | 53 +++ apps/sim/lib/integrations/oauth-service.ts | 4 +- apps/sim/lib/oauth/oauth.ts | 12 + apps/sim/tools/linear/add_label_to_issue.ts | 3 +- apps/sim/tools/linear/add_label_to_project.ts | 3 +- apps/sim/tools/linear/archive_issue.ts | 3 +- apps/sim/tools/linear/archive_label.ts | 3 +- apps/sim/tools/linear/archive_project.ts | 3 +- apps/sim/tools/linear/create_attachment.ts | 3 +- apps/sim/tools/linear/create_comment.ts | 3 +- apps/sim/tools/linear/create_customer.ts | 3 +- .../tools/linear/create_customer_request.ts | 3 +- .../tools/linear/create_customer_status.ts | 3 +- apps/sim/tools/linear/create_customer_tier.ts | 3 +- apps/sim/tools/linear/create_cycle.ts | 3 +- apps/sim/tools/linear/create_favorite.ts | 3 +- apps/sim/tools/linear/create_issue.ts | 3 +- .../sim/tools/linear/create_issue_relation.ts | 3 +- apps/sim/tools/linear/create_label.ts | 3 +- apps/sim/tools/linear/create_project.ts | 3 +- apps/sim/tools/linear/create_project_label.ts | 3 +- .../tools/linear/create_project_milestone.ts | 3 +- .../sim/tools/linear/create_project_status.ts | 3 +- .../sim/tools/linear/create_project_update.ts | 3 +- .../sim/tools/linear/create_workflow_state.ts | 3 +- apps/sim/tools/linear/delete_attachment.ts | 3 +- apps/sim/tools/linear/delete_comment.ts | 3 +- apps/sim/tools/linear/delete_customer.ts | 3 +- .../tools/linear/delete_customer_status.ts | 3 +- apps/sim/tools/linear/delete_customer_tier.ts | 3 +- apps/sim/tools/linear/delete_issue.ts | 3 +- .../sim/tools/linear/delete_issue_relation.ts | 3 +- apps/sim/tools/linear/delete_project.ts | 3 +- apps/sim/tools/linear/delete_project_label.ts | 3 +- .../tools/linear/delete_project_milestone.ts | 3 +- .../sim/tools/linear/delete_project_status.ts | 3 +- apps/sim/tools/linear/get_active_cycle.ts | 3 +- apps/sim/tools/linear/get_customer.ts | 3 +- apps/sim/tools/linear/get_cycle.ts | 3 +- apps/sim/tools/linear/get_issue.ts | 3 +- apps/sim/tools/linear/get_project.ts | 3 +- apps/sim/tools/linear/get_viewer.ts | 3 +- apps/sim/tools/linear/list_attachments.ts | 3 +- apps/sim/tools/linear/list_comments.ts | 3 +- .../tools/linear/list_customer_requests.ts | 3 +- .../tools/linear/list_customer_statuses.ts | 3 +- apps/sim/tools/linear/list_customer_tiers.ts | 3 +- apps/sim/tools/linear/list_customers.ts | 3 +- apps/sim/tools/linear/list_cycles.ts | 3 +- apps/sim/tools/linear/list_favorites.ts | 3 +- apps/sim/tools/linear/list_issue_relations.ts | 3 +- apps/sim/tools/linear/list_labels.ts | 3 +- apps/sim/tools/linear/list_notifications.ts | 3 +- apps/sim/tools/linear/list_project_labels.ts | 3 +- .../tools/linear/list_project_milestones.ts | 3 +- .../sim/tools/linear/list_project_statuses.ts | 3 +- apps/sim/tools/linear/list_project_updates.ts | 3 +- apps/sim/tools/linear/list_projects.ts | 3 +- apps/sim/tools/linear/list_teams.ts | 3 +- apps/sim/tools/linear/list_users.ts | 3 +- apps/sim/tools/linear/list_workflow_states.ts | 3 +- apps/sim/tools/linear/merge_customers.ts | 3 +- apps/sim/tools/linear/read_issues.ts | 3 +- .../tools/linear/remove_label_from_issue.ts | 3 +- .../tools/linear/remove_label_from_project.ts | 3 +- apps/sim/tools/linear/search_issues.ts | 3 +- apps/sim/tools/linear/unarchive_issue.ts | 3 +- apps/sim/tools/linear/update_attachment.ts | 3 +- apps/sim/tools/linear/update_comment.ts | 3 +- apps/sim/tools/linear/update_customer.ts | 3 +- .../tools/linear/update_customer_request.ts | 3 +- .../tools/linear/update_customer_status.ts | 3 +- apps/sim/tools/linear/update_customer_tier.ts | 3 +- apps/sim/tools/linear/update_issue.ts | 3 +- apps/sim/tools/linear/update_label.ts | 3 +- apps/sim/tools/linear/update_notification.ts | 3 +- apps/sim/tools/linear/update_project.ts | 3 +- apps/sim/tools/linear/update_project_label.ts | 3 +- .../tools/linear/update_project_milestone.ts | 3 +- .../sim/tools/linear/update_project_status.ts | 3 +- .../sim/tools/linear/update_workflow_state.ts | 3 +- apps/sim/tools/linear/utils.test.ts | 16 + apps/sim/tools/linear/utils.ts | 15 + 116 files changed, 3150 insertions(+), 79 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx create mode 100644 apps/sim/lib/credentials/token-service-accounts/descriptors.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/errors.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/server.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/asana.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/attio.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/linear.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/monday.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/notion.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/trello.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts create mode 100644 apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts create mode 100644 apps/sim/tools/linear/utils.test.ts create mode 100644 apps/sim/tools/linear/utils.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 367821cb7d0..e5b2f314fab 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -7,6 +7,11 @@ import { and, desc, eq } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { + parseTokenServiceAccountSecretBlob, + type TokenServiceAccountSecretBlob, +} from '@/lib/credentials/token-service-accounts/server' import { refreshOAuthToken } from '@/lib/oauth' import { getMicrosoftRefreshTokenExpiry, @@ -339,12 +344,39 @@ export interface ServiceAccountTokenResult { * provider is one edit and an unknown provider fails loudly instead of silently * attempting a Google JWT. */ +/** + * Loads and parses the decrypted secret blob for a token service-account + * credential (pasted long-lived provider token). Throws if the credential is + * missing or the blob doesn't belong to the expected provider. + */ +async function getTokenServiceAccountSecret( + credentialId: string, + providerId: string +): Promise { + const [credentialRow] = await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!credentialRow?.encryptedServiceAccountKey) { + throw new Error('Token service account secret not found') + } + + const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + return parseTokenServiceAccountSecretBlob(decrypted, providerId) +} + export async function resolveServiceAccountToken( credentialId: string, providerId: string | null | undefined, scopes?: string[], impersonateEmail?: string ): Promise { + if (providerId && isTokenServiceAccountProviderId(providerId)) { + const secret = await getTokenServiceAccountSecret(credentialId, providerId) + return { accessToken: secret.apiToken, domain: secret.domain } + } if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { const secret = await getAtlassianServiceAccountSecret(credentialId) return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index db1dc91c47c..ccfa665b23f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -21,6 +21,7 @@ import { SHARED_CREDENTIAL_TYPES, } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getWorkspaceMembership } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { @@ -597,6 +598,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) } + if (error instanceof TokenServiceAccountValidationError) { + logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) + } if (error instanceof DuplicateCredentialError) { return NextResponse.json( { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 16375108878..5c34d9a1bac 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -14,11 +14,16 @@ 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 { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' +import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal' import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, @@ -32,6 +37,7 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID + | TokenServiceAccountProviderId /** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' @@ -121,6 +127,22 @@ export function ConnectServiceAccountModal({ credentialDisplayName, credentialDescription, }: ConnectServiceAccountModalProps) { + const tokenDescriptor = getTokenServiceAccountDescriptor(serviceAccountProviderId) + if (tokenDescriptor) { + return ( + + ) + } if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) { return ( void + workspaceId: string + descriptor: TokenServiceAccountDescriptor + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** When set, reconnect (rotate the secret on) this credential in place. */ + credentialId?: string + initialDisplayName?: string + initialDescription?: string +} + +/** + * Generic connect modal for token-paste service accounts. Renders the fields + * declared by the provider's {@link TokenServiceAccountDescriptor} (a secret + * token, plus a domain for providers that need one) and submits through the + * same create/update credential mutations as the other service-account modals. + * Server-side verification failures are mapped from the route's `error.code`. + */ +export function TokenServiceAccountModal({ + open, + onOpenChange, + workspaceId, + descriptor, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, +}: TokenServiceAccountModalProps) { + const [apiToken, setApiToken] = useState('') + const [domain, setDomain] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + useEffect(() => { + if (open) return + setApiToken('') + setDomain('') + setDisplayName(initialDisplayName ?? '') + setDescription(initialDescription ?? '') + setError(null) + }, [open, initialDisplayName, initialDescription]) + + const tokenField = descriptor.fields.find((field) => field.id === 'apiToken') + const domainField = descriptor.fields.find((field) => field.id === 'domain') + + const trimmedToken = apiToken.trim() + const normalizedDomain = normalizeDomainInput(domain) + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = !trimmedToken || (Boolean(domainField) && !normalizedDomain) || isPending + + const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => { + if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined + return field.hintPattern.test(value) ? undefined : field.hintMessage + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled) return + try { + const secretFields = { + apiToken: trimmedToken, + ...(domainField ? { domain: normalizedDomain } : {}), + } + if (credentialId) { + await updateCredential.mutateAsync({ + credentialId, + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } else { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: descriptor.providerId, + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } + onOpenChange(false) + } catch (err: unknown) { + setError(messageForTokenAccountError(err, descriptor)) + logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) + } + } + + return ( + + onOpenChange(false)}> + Add {serviceName} service account + + + {tokenField && ( + + { + setApiToken(value) + if (error) setError(null) + }} + placeholder={tokenField.placeholder} + name={`${descriptor.providerId}_api_token`} + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + + )} + + {domainField && ( + { + setDomain(value) + if (error) setError(null) + }} + placeholder={domainField.placeholder} + autoComplete='off' + required + error={hintFor(domainField, normalizedDomain)} + /> + )} + + + + + + {error} + + onOpenChange(false)} + secondaryActions={[ + { + label: 'Setup guide', + onClick: () => openDocs(descriptor.docsUrl), + }, + ]} + primaryAction={{ + label: isPending ? 'Adding...' : 'Add service account', + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index ecd15b68afb..aad45af4b64 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthProvider, @@ -195,6 +196,19 @@ export const createCredentialBodySchema = z } return } + const tokenRequiredFields = TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS[data.providerId ?? ''] + if (tokenRequiredFields) { + for (const field of tokenRequiredFields) { + if (!data[field]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${field} is required for ${data.providerId} credentials`, + path: [field], + }) + } + } + return + } if (!data.serviceAccountJson) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 968e0a2fb14..e49964ba004 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server' import { encryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' import { deleteWorkspaceEnvCredentials, @@ -152,6 +153,14 @@ export async function performUpdateCredential( providerErrorCode: error.code, } } + if (error instanceof TokenServiceAccountValidationError) { + return { + success: false, + error: error.code, + errorCode: 'validation', + providerErrorCode: error.code, + } + } throw error } } diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 579fae92531..605a3d25d81 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -6,6 +6,14 @@ import { normalizeAtlassianDomain, validateAtlassianServiceAccount, } from '@/lib/credentials/atlassian-service-account' +import { + getTokenServiceAccountDescriptor, + 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, @@ -120,6 +128,40 @@ export async function verifyAndBuildServiceAccountSecret( } } + const tokenDescriptor = getTokenServiceAccountDescriptor(providerId) + if (tokenDescriptor) { + const apiToken = fields.apiToken?.trim() + const domain = fields.domain?.trim() + const requiresDomain = tokenDescriptor.fields.some((field) => field.id === 'domain') + if (!apiToken || (requiresDomain && !domain)) { + const required = tokenDescriptor.fields.map((field) => field.id).join(' and ') + throw new ServiceAccountSecretError( + `${required} ${tokenDescriptor.fields.length > 1 ? 'are' : 'is'} required for ${tokenDescriptor.serviceLabel} service account credentials` + ) + } + const validator = getTokenServiceAccountValidator(providerId) + if (!validator) { + throw new ServiceAccountSecretError( + `No validator registered for service-account provider ${providerId}` + ) + } + 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, + } + } + const { serviceAccountJson } = fields if (!serviceAccountJson) { throw new ServiceAccountSecretError( diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts new file mode 100644 index 00000000000..a78672a807c --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -0,0 +1,313 @@ +/** + * Client-safe descriptors for token-paste service-account providers. + * + * A token service account is a `service_account`-type credential where a + * workspace admin pastes a long-lived provider token (private-app token, PAT, + * API key, …) instead of running an OAuth flow — mirroring the Atlassian + * service-account pattern: the token is verified once server-side, encrypted, + * and returned as the access token at execution time with no exchange or + * refresh. This module holds only UI/contract metadata (field lists, labels, + * docs links); server-side verification lives in + * `@/lib/credentials/token-service-accounts/server`. + */ + +/** Discriminator stored inside every encrypted token service-account secret blob. */ +export const TOKEN_SERVICE_ACCOUNT_SECRET_TYPE = 'token_service_account' as const + +/** Contract field ids a token service-account modal may collect. */ +export type TokenServiceAccountFieldId = 'apiToken' | 'domain' + +export interface TokenServiceAccountField { + id: TokenServiceAccountFieldId + label: string + placeholder: string + /** Rendered with SecretInput and never echoed back. */ + secret: boolean + /** Soft-format hint shown while the current value doesn't match `hintPattern`. */ + hintPattern?: RegExp + hintMessage?: string +} + +export interface TokenServiceAccountDescriptor { + /** Stable credential `providerId` (`-service-account`). */ + providerId: string + /** Human service label used in modal copy and error messages (e.g. "HubSpot"). */ + serviceLabel: string + /** Vendor noun for the pasted secret (e.g. "private app access token"). */ + tokenNoun: string + 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', + 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', + 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', + 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: + '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', + 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', + 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', + 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', + 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', + 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', + }, + [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Webflow', + tokenNoun: 'site API 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: + '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', + 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', + 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', + fields: [ + { + id: 'apiToken', + label: 'API access token', + placeholder: 'Paste API access token', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/wealthbox-service-account', + helpText: 'API access must be enabled for your Wealthbox account before tokens can be created.', + }, +} + +/** + * Required contract fields per token service-account provider, consumed by the + * `createCredentialBodySchema` superRefine so validation errors name the exact + * missing field. Derived from each descriptor's field list. + */ +export const TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = + Object.fromEntries( + Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).map((descriptor) => [ + descriptor.providerId, + descriptor.fields.map((field) => field.id), + ]) + ) + +export function isTokenServiceAccountProviderId( + value: string | null | undefined +): value is TokenServiceAccountProviderId { + return Boolean(value && value in TOKEN_SERVICE_ACCOUNT_DESCRIPTORS) +} + +export function getTokenServiceAccountDescriptor( + providerId: string | null | undefined +): TokenServiceAccountDescriptor | undefined { + return isTokenServiceAccountProviderId(providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId] + : undefined +} diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts new file mode 100644 index 00000000000..85059a3f459 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -0,0 +1,63 @@ +import { truncate } from '@sim/utils/string' + +/** + * Discrete validation failure codes returned to the client for token + * service-account credentials. The UI maps each code to a human message; raw + * provider response bodies stay in server logs. + */ +export type TokenServiceAccountValidationCode = + | 'invalid_credentials' + | 'site_not_found' + | 'provider_unavailable' + +export class TokenServiceAccountValidationError extends Error { + constructor( + public readonly code: TokenServiceAccountValidationCode, + public readonly status: number, + public readonly logDetail?: Record + ) { + super(code) + this.name = 'TokenServiceAccountValidationError' + } +} + +const ERROR_SNIPPET_MAX_LENGTH = 500 + +/** + * Reads a bounded snippet of a provider error body for server logs. Never + * throws — an unreadable body logs as an empty string. + */ +export async function readProviderErrorSnippet(res: Response): Promise { + try { + return truncate(await res.text(), ERROR_SNIPPET_MAX_LENGTH) + } catch { + return '' + } +} + +/** + * Maps a failed provider verification response to the standard error split: + * 401/403 mean the pasted token was rejected (`invalid_credentials`), anything + * else non-2xx means the provider couldn't be reached or misbehaved + * (`provider_unavailable`) — never blame the token for a provider outage. + */ +export async function throwForProviderResponse( + res: Response, + step: string, + context: Record = {} +): Promise { + if (res.ok) return + const body = await readProviderErrorSnippet(res) + if (res.status === 401 || res.status === 403) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step, + body, + ...context, + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step, + body, + ...context, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts new file mode 100644 index 00000000000..42086788def --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -0,0 +1,106 @@ +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, + 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, + TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, + WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID, + WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { validateAirtableServiceAccount } from '@/lib/credentials/token-service-accounts/validators/airtable' +import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana' +import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio' +import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom' +import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot' +import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear' +import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday' +import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion' +import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' +import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello' +import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox' +import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow' + +/** Raw fields a token service-account validator receives (already trimmed). */ +export interface TokenServiceAccountFields { + apiToken: string + domain?: string +} + +/** Result of a successful provider verification. */ +export interface TokenServiceAccountValidationResult { + /** Default display name when the user didn't provide one. */ + displayName: string + /** Non-secret identifiers recorded in the audit log (e.g. portal/workspace id). */ + auditMetadata: Record + /** + * Non-secret metadata persisted inside the encrypted blob alongside the + * token (e.g. normalized store domain, portal id) for later debugging. + */ + storedMetadata?: Record + /** Normalized domain to persist instead of the raw user input (when collected). */ + normalizedDomain?: string +} + +export type TokenServiceAccountValidator = ( + fields: TokenServiceAccountFields +) => Promise + +/** + * Server-side verification registry for token service-account providers. Keys + * must stay in lockstep with `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` — a + * descriptor without a validator fails loudly at create time. + */ +const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record = { + [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 TOKEN_SERVICE_ACCOUNT_VALIDATORS[providerId] +} + +/** + * Shape of the decrypted secret blob persisted for token service accounts. + * `providerId` is stored inside the blob so a mismatched credential row fails + * loudly at resolution time instead of returning another provider's token. + */ +export interface TokenServiceAccountSecretBlob { + type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE + providerId: string + apiToken: string + domain?: string + metadata?: Record +} + +export function parseTokenServiceAccountSecretBlob( + decrypted: string, + expectedProviderId: string +): TokenServiceAccountSecretBlob { + const parsed = JSON.parse(decrypted) as TokenServiceAccountSecretBlob + if ( + parsed.type !== TOKEN_SERVICE_ACCOUNT_SECRET_TYPE || + parsed.providerId !== expectedProviderId || + !parsed.apiToken + ) { + throw new Error('Stored token service-account secret is malformed') + } + return parsed +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts new file mode 100644 index 00000000000..9937f1d9d11 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts @@ -0,0 +1,76 @@ +/** + * @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', + }, + }) + }) + + 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 on 500', async () => { + mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 })) + + const error = await validateAirtableServiceAccount({ apiToken: 'pat' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(500) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts new file mode 100644 index 00000000000..e5c9c989ce5 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -0,0 +1,54 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +/** + * Validates an Airtable personal access token (including Enterprise + * service-account PATs) by calling the `whoami` meta endpoint, which requires + * no scopes. Airtable documents no body shape for 401 responses, so failures + * key off the status code alone: 401/403 mean the token was rejected, any + * other non-2xx means Airtable is unavailable. + * + * `email` is present only when the token has the `user.email:read` scope, and + * `scopes` is returned only for OAuth access tokens (never PATs) — both are + * optional and their absence is not a failure. + */ +export async function validateAirtableServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const whoamiRes = await fetch('https://api.airtable.com/v0/meta/whoami', { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(whoamiRes, 'whoami') + + const whoami = (await whoamiRes.json()) as { + id?: string + email?: string + scopes?: string[] + } + if (!whoami.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'whoami', + reason: 'missing id in response', + }) + } + + const storedMetadata: Record = { userId: whoami.id } + if (whoami.scopes) { + storedMetadata.scopes = whoami.scopes.join(' ') + } + + return { + displayName: whoami.email ?? `Airtable user ${whoami.id}`, + auditMetadata: { airtableUserId: whoami.id }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts new file mode 100644 index 00000000000..e0645cde2f1 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts @@ -0,0 +1,96 @@ +/** + * @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', + }, + } + ) + }) + + 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 200 response missing data.gid to provider_unavailable', async () => { + mockFetch.mockResolvedValue(jsonResponse(200, { data: { name: 'No Gid' } })) + + const error = await validateAsanaServiceAccount({ apiToken: 'token-4' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts new file mode 100644 index 00000000000..d37df7cfc7d --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -0,0 +1,56 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +const ASANA_ME_URL = 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email' + +interface AsanaMeResponse { + data?: { + gid?: string + name?: string + email?: string + } +} + +/** + * Validates an Asana service-account (or personal access) token by calling + * `GET /users/me`. Asana tokens are documented as opaque, so no format checks + * are performed — the live API call is the only gate. 401/403 mean the token + * was rejected; any other non-2xx means Asana is unavailable. + */ +export async function validateAsanaServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch(ASANA_ME_URL, { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(res, 'users_me') + + const body = (await res.json()) as AsanaMeResponse + const gid = body.data?.gid + if (!gid) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'users_me', + reason: 'missing data.gid in response', + }) + } + + const name = body.data?.name + const email = body.data?.email + const storedMetadata: Record = { userGid: gid } + if (email) storedMetadata.email = email + + return { + displayName: name || email || `Asana user ${gid}`, + auditMetadata: { asanaUserGid: gid }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts new file mode 100644 index 00000000000..81eba1dc67a --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio' + +const mockFetch = vi.fn() + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +async function expectValidationError( + promise: Promise, + code: string +): Promise { + const error = await promise.then( + () => { + throw new Error('expected validation to throw') + }, + (e: unknown) => e + ) + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect((error as TokenServiceAccountValidationError).code).toBe(code) + return error as TokenServiceAccountValidationError +} + +describe('validateAttioServiceAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns workspace info on an active token', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + active: true, + workspace_id: 'ws-123', + workspace_name: 'Acme CRM', + workspace_slug: 'acme-crm', + }) + ) + + const result = await validateAttioServiceAccount({ apiToken: 'attio-token' }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.attio.com/v2/self', { + headers: { + Authorization: 'Bearer attio-token', + Accept: 'application/json', + }, + }) + 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 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 revoked token (active === false) to invalid_credentials', async () => { + mockFetch.mockResolvedValue(jsonResponse({ active: false })) + + await expectValidationError( + validateAttioServiceAccount({ apiToken: 'revoked-token' }), + 'invalid_credentials' + ) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts new file mode 100644 index 00000000000..72e451a43f0 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -0,0 +1,69 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} 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. + */ +export async function validateAttioServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch(ATTIO_SELF_URL, { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(res, 'self') + + let self: AttioSelfResponse + try { + self = (await res.json()) as AttioSelfResponse + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'self', + reason: 'non-JSON response body', + }) + } + + if (self.active === false) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'self', + reason: 'token is revoked (active === false)', + }) + } + if (self.active !== true || !self.workspace_id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'self', + reason: 'response missing active flag or workspace_id', + }) + } + + const storedMetadata: Record = { workspaceId: self.workspace_id } + if (self.workspace_slug) { + storedMetadata.workspaceSlug = self.workspace_slug + } + + return { + displayName: self.workspace_name || 'Attio workspace', + auditMetadata: { attioWorkspaceId: self.workspace_id }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts new file mode 100644 index 00000000000..7842e4485eb --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts @@ -0,0 +1,79 @@ +/** + * @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', + }, + }) + }) + + 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-success envelope to provider_unavailable', async () => { + mockFetch.mockResolvedValue(jsonResponse(200, { status: 'error', data: { id: 42 } })) + + const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts new file mode 100644 index 00000000000..6d5ce21ee46 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -0,0 +1,59 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +const CALCOM_ME_URL = 'https://api.cal.com/v2/me' + +interface CalcomMeResponse { + status?: string + data?: { + id?: number + username?: string + email?: string + } +} + +/** + * Validates a Cal.com API key by calling `GET /v2/me`. Cal.com accepts an API + * key in the same `Authorization: Bearer` slot the tools use for OAuth tokens, + * and this endpoint needs no `cal-api-version` header (unlike the per-resource + * tool endpoints). 401/403 mean the key was rejected; any other non-2xx — or a + * 200 without the standard `{ status: 'success', data }` envelope — means + * Cal.com is unavailable. + */ +export async function validateCalcomServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch(CALCOM_ME_URL, { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(res, 'me') + + const body = (await res.json()) as CalcomMeResponse + if (body.status !== 'success' || body.data?.id === undefined) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'me', + reason: 'unexpected response envelope from /v2/me', + }) + } + + const userId = String(body.data.id) + const username = body.data.username + const email = body.data.email + const storedMetadata: Record = { userId } + if (email) storedMetadata.email = email + + return { + displayName: username || email || 'Cal.com account', + auditMetadata: { calcomUserId: userId }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts new file mode 100644 index 00000000000..13a4d99d9b5 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -0,0 +1,106 @@ +/** + * @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 +} + +const mockFetch = vi.fn() + +describe('validateHubspotServiceAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns displayName and metadata on success', async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url === TOKEN_INFO_URL) { + return jsonResponse(200, { + userId: 111, + hubId: 12345, + appId: 222, + scopes: ['tickets'], + }) + } + if (url === ACCOUNT_INFO_URL) { + return jsonResponse(403, { status: 'error', category: 'MISSING_SCOPES' }) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + const result = await validateHubspotServiceAccount(FIELDS) + + expect(result).toEqual({ + displayName: 'HubSpot portal 12345', + auditMetadata: { hubspotHubId: '12345' }, + storedMetadata: { hubId: '12345', appId: '222', userId: '111' }, + }) + + const [, init] = mockFetch.mock.calls[0] + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb') + expect(JSON.parse(init.body)).toEqual({ tokenKey: 'pat-na1-aaaa-bbbb' }) + }) + + it('upgrades displayName to uiDomain when account-info succeeds', async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url === TOKEN_INFO_URL) return jsonResponse(200, { hubId: 12345 }) + if (url === ACCOUNT_INFO_URL) return jsonResponse(200, { uiDomain: 'app.hubspot.com' }) + throw new Error(`unexpected fetch: ${url}`) + }) + + const result = await validateHubspotServiceAccount(FIELDS) + expect(result.displayName).toBe('app.hubspot.com') + }) + + it('throws invalid_credentials on 401', async () => { + mockFetch.mockResolvedValue( + jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' }) + ) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' })) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 503, + }) + }) + + it('throws provider_unavailable on malformed success body', async () => { + mockFetch.mockResolvedValue(jsonResponse(200, { unexpected: true })) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts new file mode 100644 index 00000000000..a967da0ce06 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -0,0 +1,92 @@ +import { + 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[] +} + +/** + * Best-effort display-name upgrade via the account-info endpoint. Its + * documented required scope (`oauth`) cannot be granted to private apps, so a + * 403 (or any other failure) is expected and must never surface as a bad + * token — callers fall back to the hubId-based name silently. + */ +async function fetchPortalUiDomain(accessToken: string): Promise { + try { + const res = await fetch(ACCOUNT_INFO_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + if (!res.ok) return undefined + const details = (await res.json()) as { uiDomain?: string } + return typeof details.uiDomain === 'string' && details.uiDomain ? details.uiDomain : undefined + } catch { + return undefined + } +} + +/** + * Validates a HubSpot private app access token by calling the access-token-info + * endpoint. Both the JSON body (`tokenKey`) and the `Authorization: Bearer` + * header are sent — the header is optional for NA (`pat-na1`) tokens but + * required for EU (`pat-eu1`) tokens, so one code path covers both regions. + * The endpoint requires no scopes, so it validates any private-app token. + */ +export async function validateHubspotServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const accessToken = fields.apiToken + + const res = await fetch(TOKEN_INFO_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ tokenKey: accessToken }), + }) + await throwForProviderResponse(res, 'access_token_info') + + let tokenInfo: HubspotTokenInfo + try { + tokenInfo = (await res.json()) as HubspotTokenInfo + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'access_token_info', + reason: 'response body is not valid JSON', + }) + } + 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 uiDomain = await fetchPortalUiDomain(accessToken) + + const storedMetadata: Record = { hubId } + if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId) + if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId) + + return { + displayName: uiDomain ?? `HubSpot portal ${hubId}`, + auditMetadata: { hubspotHubId: hubId }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts new file mode 100644 index 00000000000..c8eba25b33b --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -0,0 +1,93 @@ +/** + * @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' + +const mockFetch = vi.fn() + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('validateLinearServiceAccount', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('returns viewer and organization metadata on success', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + data: { + viewer: { id: 'viewer-1', name: 'Jane Ops', email: 'jane@acme.com' }, + organization: { id: 'org-1', name: 'Acme' }, + }, + }) + ) + + const result = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' }) + + expect(result).toEqual({ + displayName: 'Acme', + auditMetadata: { linearOrganizationId: 'org-1' }, + storedMetadata: { viewerId: 'viewer-1', organizationId: 'org-1' }, + }) + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.linear.app/graphql') + expect(init.method).toBe('POST') + const headers = init.headers as Record + expect(headers.Authorization).toBe('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 500', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: [{ message: 'Internal error' }] })) + + const error = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(500) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts new file mode 100644 index 00000000000..faa6a1232af --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -0,0 +1,114 @@ +import { + TokenServiceAccountValidationError, + readProviderErrorSnippet, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +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. + */ +function hasAuthenticationError(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/i.test(value)) + }) +} + +/** + * Validates a Linear personal API key by running the `viewer` query against + * the fixed GraphQL endpoint. Personal keys are sent as a bare + * `Authorization: ` header — no `Bearer` prefix — per Linear's official + * docs (the Bearer form is reserved for OAuth access tokens). GraphQL can + * return transport 200 with an `errors` array, so both the HTTP status and + * the body are inspected. + */ +export async function validateLinearServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + Authorization: fields.apiToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: VIEWER_QUERY }), + }) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status === 400 || res.status === 401 || res.status === 403) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'viewer', + body, + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'viewer', + body, + }) + } + + const payload = (await res.json()) as LinearViewerResponse + if (hasAuthenticationError(payload.errors)) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'viewer', + reason: 'GraphQL authentication error in 200 response', + }) + } + + const viewer = payload.data?.viewer + if (!viewer?.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'viewer', + reason: 'missing viewer in response', + }) + } + + const organization = payload.data?.organization + const storedMetadata: Record = { viewerId: viewer.id } + const auditMetadata: Record = {} + if (organization?.id) { + storedMetadata.organizationId = organization.id + auditMetadata.linearOrganizationId = organization.id + } + + return { + displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace', + auditMetadata, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts new file mode 100644 index 00000000000..ca891d63e50 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -0,0 +1,98 @@ +/** + * @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 } }', + }), + }) + }) + + 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 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) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts new file mode 100644 index 00000000000..9380a657f03 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -0,0 +1,97 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} 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 }> + error_message?: string +} + +/** + * Mirrors `extractMondayError` from `@/tools/monday/utils`: monday returns + * HTTP 200 for application-level failures, signalled by a GraphQL `errors` + * array or a top-level `error_message` field. + */ +function extractBodyError(body: MondayValidationBody): string | null { + if (Array.isArray(body.errors) && body.errors.length > 0) { + const messages = body.errors.map((e) => e.message).filter(Boolean) + return messages.length > 0 ? messages.join('; ') : 'Unknown Monday.com API error' + } + if (body.error_message) { + return body.error_message + } + return null +} + +/** + * Validates a monday.com personal API token by running a `me`/`account` + * GraphQL query. monday tools send the token as a bare `Authorization` + * header (no `Bearer` prefix) with a pinned `API-Version`, so validation + * reuses `mondayHeaders` to exercise exactly the shape tools use. + * + * monday returns HTTP 200 for application-level errors, so a 200 response + * is only a success once the body carries no `errors` array or + * `error_message` and `data.me.id` is present. + */ +export async function validateMondayServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch(MONDAY_API_URL, { + method: 'POST', + headers: mondayHeaders(fields.apiToken), + body: JSON.stringify({ query: VALIDATION_QUERY }), + }) + await throwForProviderResponse(res, 'me') + + let body: MondayValidationBody + try { + body = (await res.json()) as MondayValidationBody + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'me', + reason: 'non-JSON response body', + }) + } + + const bodyError = extractBodyError(body) + if (bodyError) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'me', + body: bodyError, + }) + } + + const me = body.data?.me + const account = body.data?.account + if (!me?.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'me', + reason: 'missing me.id in response', + }) + } + + const userId = String(me.id) + const accountId = account?.id != null ? String(account.id) : '' + const storedMetadata: Record = { accountId, userId } + if (account?.slug) { + storedMetadata.accountSlug = account.slug + } + + return { + displayName: account?.name || me.name || me.email || `monday user ${userId}`, + auditMetadata: { mondayAccountId: accountId }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts new file mode 100644 index 00000000000..f4c53df4729 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -0,0 +1,96 @@ +/** + * @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', + }, + }) + }) + + 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 on 502', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(502, { object: 'error' })) + + const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts new file mode 100644 index 00000000000..ce101bfb868 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -0,0 +1,63 @@ +import { + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +/** + * Pinned to the same API version Sim's Notion tools send + * (`apps/sim/tools/notion/read.ts`) so validation and execution exercise the + * same API surface. + */ +const NOTION_VERSION = '2022-06-28' + +interface NotionBotUser { + id?: string + name?: string | null + bot?: { + workspace_name?: string | null + } +} + +/** + * Validates a Notion internal integration secret by calling + * `GET /v1/users/me`, which is documented to succeed with any capability + * level. Tokens are treated as opaque strings — Notion explicitly advises + * against prefix/regex validation (`ntn_` and legacy `secret_` are both + * valid). + */ +export async function validateNotionServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetch('https://api.notion.com/v1/users/me', { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + 'Notion-Version': NOTION_VERSION, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(res, 'users_me') + + const me = (await res.json()) as NotionBotUser + if (!me.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'users_me', + reason: 'missing id in response', + }) + } + + const workspaceName = me.bot?.workspace_name || undefined + const storedMetadata: Record = { botId: me.id } + if (workspaceName) { + storedMetadata.workspaceName = workspaceName + } + + return { + displayName: me.name || workspaceName || 'Notion integration', + auditMetadata: { notionBotId: me.id }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts new file mode 100644 index 00000000000..5514266ef85 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -0,0 +1,114 @@ +/** + * @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', + email: 'ops@acme.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 email } }' }), + } + ) + }) + + 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) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts new file mode 100644 index 00000000000..e6c2dc7a786 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -0,0 +1,117 @@ +import { + TokenServiceAccountValidationError, + readProviderErrorSnippet, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +/** + * Pinned to the same Admin API version every Sim Shopify tool uses so + * validation and tool runtime can never diverge. + */ +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 email } }' + +interface ShopifyShopResponse { + data?: { + shop?: { + name?: string + myshopifyDomain?: string + email?: string + } + } + errors?: unknown +} + +/** Strips the protocol and trailing slashes and lowercases the store domain. */ +function normalizeShopifyDomain(rawDomain: string): string { + return rawDomain + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') + .toLowerCase() +} + +/** + * Validates a Shopify custom-app Admin API access token by running the + * scope-free `shop` query against the store's GraphQL Admin API — the exact + * URL and header shape every Sim Shopify tool uses. Unknown shops return 404 + * (wildcard DNS means the host always resolves), which maps to + * `site_not_found`. + */ +export async function validateShopifyServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const domain = normalizeShopifyDomain(fields.domain ?? '') + if (!SHOPIFY_HOST_REGEX.test(domain)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + domain, + reason: 'host is not a Shopify store domain (expected *.myshopify.com)', + }) + } + + const res = await fetch(`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 }), + }) + + 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 }) + + let payload: ShopifyShopResponse + try { + payload = (await res.json()) as ShopifyShopResponse + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'shop_query', + domain, + reason: 'response body is not valid JSON', + }) + } + + const shop = payload.data?.shop + if (payload.errors || !shop) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'shop_query', + domain, + reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response', + }) + } + + const shopName = typeof shop.name === 'string' && shop.name ? shop.name : undefined + const apiDomain = + typeof shop.myshopifyDomain === 'string' + ? normalizeShopifyDomain(shop.myshopifyDomain) + : undefined + const canonicalDomain = apiDomain && SHOPIFY_HOST_REGEX.test(apiDomain) ? apiDomain : domain + const storedMetadata: Record = { shopDomain: canonicalDomain } + if (shopName) storedMetadata.shopName = shopName + + return { + displayName: shopName ?? canonicalDomain, + auditMetadata: { shopifyShopDomain: canonicalDomain }, + storedMetadata, + normalizedDomain: canonicalDomain, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts new file mode 100644 index 00000000000..f515211e739 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts @@ -0,0 +1,103 @@ +/** + * @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', 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 500', async () => { + mockFetch.mockResolvedValue(jsonResponse(500, { message: 'unavailable' })) + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 500, + }) + }) + + it('throws provider_unavailable without fetching when the API key is not configured', async () => { + mockEnv.TRELLO_API_KEY = undefined + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 500, + logDetail: { reason: 'Trello API key is not configured' }, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('throws provider_unavailable on missing id in success body', async () => { + mockFetch.mockResolvedValue(jsonResponse(200, { fullName: 'Sim Bot' })) + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts new file mode 100644 index 00000000000..30258b94d26 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -0,0 +1,73 @@ +import { env } from '@/lib/core/config/env' +import { + 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). + */ +export async function validateTrelloServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const apiKey = env.TRELLO_API_KEY + if (!apiKey) { + throw new TokenServiceAccountValidationError('provider_unavailable', 500, { + reason: 'Trello API key is not configured', + }) + } + + const url = new URL(MEMBERS_ME_URL) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', fields.apiToken) + url.searchParams.set('fields', 'id,fullName,username') + + const res = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, + }) + await throwForProviderResponse(res, 'members_me') + + let member: TrelloMember + try { + member = (await res.json()) as TrelloMember + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'members_me', + reason: 'response body is not valid JSON', + }) + } + if (typeof member?.id !== 'string' || !member.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'members_me', + reason: 'missing id in response', + }) + } + + const storedMetadata: Record = { memberId: member.id } + if (typeof member.username === 'string' && member.username) { + storedMetadata.username = member.username + } + + return { + displayName: member.fullName || member.username || `Trello member ${member.id}`, + auditMetadata: { trelloMemberId: member.id }, + storedMetadata, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts new file mode 100644 index 00000000000..cb19a7bff9f --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox' + +const ME_URL = 'https://api.crmworkspace.com/v1/me' + +const FIELDS = { apiToken: '12345678901234567890123456789012' } + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +const mockFetch = vi.fn() + +describe('validateWealthboxServiceAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns displayName and metadata on Bearer success', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { + name: 'Bill Jones', + email: 'bill@example.com', + current_user: { id: 42, email: 'bill@example.com', name: 'Bill Jones' }, + }) + ) + + const result = await validateWealthboxServiceAccount(FIELDS) + + expect(result).toEqual({ + displayName: 'Bill Jones', + auditMetadata: { wealthboxUserId: '42' }, + storedMetadata: { userId: '42', email: 'bill@example.com' }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(ME_URL) + expect(init.headers.Authorization).toBe(`Bearer ${FIELDS.apiToken}`) + }) + + it('throws invalid_credentials when Bearer 401s but ACCESS_TOKEN succeeds', async () => { + mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record + if (headers.Authorization) return jsonResponse(401, { error: 'No valid API key provided' }) + if (headers.ACCESS_TOKEN) return jsonResponse(200, { name: 'Bill Jones' }) + throw new Error('unexpected fetch headers') + }) + + await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + logDetail: expect.objectContaining({ + reason: 'token accepted only via ACCESS_TOKEN header — not compatible with Sim tools', + }), + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('throws invalid_credentials when both Bearer and ACCESS_TOKEN 401', async () => { + mockFetch.mockResolvedValue(jsonResponse(401, { error: 'No valid API key provided' })) + + await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' })) + + await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 503, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts new file mode 100644 index 00000000000..93670f7b1c6 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -0,0 +1,97 @@ +import { + TokenServiceAccountValidationError, + readProviderErrorSnippet, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +const ME_URL = 'https://api.crmworkspace.com/v1/me' + +interface WealthboxMeResponse { + name?: string + email?: string + current_user?: { + id?: number + email?: string + name?: string + } +} + +/** + * Best-effort probe with the officially documented `ACCESS_TOKEN` header. + * Used only to distinguish "bogus token" from "real token that Wealthbox + * refuses over Bearer" in server logs — never to accept the credential, + * because Sim's Wealthbox tools send `Authorization: Bearer` at runtime. + */ +async function probeAccessTokenHeader(apiToken: string): Promise { + try { + const res = await fetch(ME_URL, { + headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' }, + }) + 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. + */ +export async function validateWealthboxServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const apiToken = fields.apiToken + + const res = await fetch(ME_URL, { + headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, + }) + + 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') + + let me: WealthboxMeResponse + try { + me = (await res.json()) as WealthboxMeResponse + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'me', + reason: 'response body is not valid JSON', + }) + } + + const displayName = me.name || me.email || 'Wealthbox account' + const userId = + typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined + const email = me.email || me.current_user?.email + + const auditMetadata: Record = {} + if (userId) auditMetadata.wealthboxUserId = userId + + const storedMetadata: Record = {} + if (userId) storedMetadata.userId = userId + if (email) storedMetadata.email = email + + return { displayName, auditMetadata, storedMetadata } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts new file mode 100644 index 00000000000..e4cc65ed881 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -0,0 +1,89 @@ +/** + * @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', + }, + }) + }) + + 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 200 with empty sites array', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { sites: [] })) + + const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts new file mode 100644 index 00000000000..af2c36f1ff9 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -0,0 +1,53 @@ +import { + throwForProviderResponse, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +interface WebflowSite { + id?: string + displayName?: string + shortName?: string +} + +/** + * Validates a Webflow site API token by listing sites via the Data API v2. + * A site token is bound to exactly one site, so `GET /v2/sites` returns that + * site and doubles as the whoami-equivalent (site tokens cannot call the + * authorization endpoints). Requires the `sites:read` scope — a token missing + * it surfaces as 401/403, which maps to `invalid_credentials`; any other + * non-2xx means Webflow is unavailable. A 200 with an empty `sites` array is + * treated as `provider_unavailable` since a site token should always list its + * one site. + */ +export async function validateWebflowServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const sitesRes = await fetch('https://api.webflow.com/v2/sites', { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, + }) + await throwForProviderResponse(sitesRes, 'list_sites') + + const payload = (await sitesRes.json()) as { sites?: WebflowSite[] } + const site = payload.sites?.[0] + if (!site?.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'list_sites', + reason: 'missing or empty sites array in response', + }) + } + + const displayName = site.displayName || site.shortName || 'Webflow site' + + return { + displayName, + auditMetadata: { webflowSiteId: site.id }, + storedMetadata: { siteId: site.id, siteName: displayName }, + } +} diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index dc61fd34759..7f6af144aeb 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import integrationsJson from '@/lib/integrations/integrations.json' import type { Integration } from '@/lib/integrations/types' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { getServiceConfigByServiceId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' @@ -38,7 +39,8 @@ function asServiceAccountProviderId( if ( value === 'google-service-account' || value === 'atlassian-service-account' || - value === SLACK_CUSTOM_BOT_PROVIDER_ID + value === SLACK_CUSTOM_BOT_PROVIDER_ID || + isTokenServiceAccountProviderId(value) ) { return value } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index d472d8d611c..def1084fab7 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -589,6 +589,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Airtable', description: 'Manage Airtable bases, tables, and records.', providerId: 'airtable', + serviceAccountProviderId: 'airtable-service-account', icon: AirtableIcon, baseProviderIcon: AirtableIcon, scopes: [ @@ -610,6 +611,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Notion', description: 'Connect to your Notion workspace to manage pages and databases.', providerId: 'notion', + serviceAccountProviderId: 'notion-service-account', icon: NotionIcon, baseProviderIcon: NotionIcon, scopes: [], @@ -625,6 +627,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Linear', description: 'Manage issues and projects in Linear.', providerId: 'linear', + serviceAccountProviderId: 'linear-service-account', icon: LinearIcon, baseProviderIcon: LinearIcon, scopes: ['read', 'write'], @@ -640,6 +643,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Monday.com', description: 'Manage boards, items, and groups in Monday.com.', providerId: 'monday', + serviceAccountProviderId: 'monday-service-account', icon: MondayIcon, baseProviderIcon: MondayIcon, scopes: [ @@ -701,6 +705,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Shopify', description: 'Manage products, orders, and customers in your Shopify store.', providerId: 'shopify', + serviceAccountProviderId: 'shopify-service-account', icon: ShopifyIcon, baseProviderIcon: ShopifyIcon, scopes: [ @@ -797,6 +802,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Wealthbox', description: 'Manage contacts, notes, and tasks in your Wealthbox CRM.', providerId: 'wealthbox', + serviceAccountProviderId: 'wealthbox-service-account', icon: WealthboxIcon, baseProviderIcon: WealthboxIcon, scopes: ['login', 'data'], @@ -812,6 +818,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Webflow', description: 'Manage Webflow CMS collections, sites, and content.', providerId: 'webflow', + serviceAccountProviderId: 'webflow-service-account', icon: WebflowIcon, baseProviderIcon: WebflowIcon, scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'], @@ -827,6 +834,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Trello', description: 'Manage Trello boards, cards, and workflows.', providerId: 'trello', + serviceAccountProviderId: 'trello-service-account', icon: TrelloIcon, baseProviderIcon: TrelloIcon, scopes: ['read', 'write'], @@ -842,6 +850,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Asana', description: 'Manage Asana projects, tasks, and workflows.', providerId: 'asana', + serviceAccountProviderId: 'asana-service-account', icon: AsanaIcon, baseProviderIcon: AsanaIcon, scopes: ['default'], @@ -857,6 +866,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Attio', description: 'Manage records, notes, tasks, lists, comments, and more in Attio CRM.', providerId: 'attio', + serviceAccountProviderId: 'attio-service-account', icon: AttioIcon, baseProviderIcon: AttioIcon, scopes: [ @@ -882,6 +892,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Cal.com', description: 'Manage Cal.com bookings, event types, and schedules.', providerId: 'calcom', + serviceAccountProviderId: 'calcom-service-account', icon: CalComIcon, baseProviderIcon: CalComIcon, scopes: [], @@ -935,6 +946,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'HubSpot', description: 'Access and manage your HubSpot CRM data.', providerId: 'hubspot', + serviceAccountProviderId: 'hubspot-service-account', icon: HubspotIcon, baseProviderIcon: HubspotIcon, scopes: [ diff --git a/apps/sim/tools/linear/add_label_to_issue.ts b/apps/sim/tools/linear/add_label_to_issue.ts index 1b320c3f3c0..3067d332c53 100644 --- a/apps/sim/tools/linear/add_label_to_issue.ts +++ b/apps/sim/tools/linear/add_label_to_issue.ts @@ -1,4 +1,5 @@ import type { LinearAddLabelResponse, LinearAddLabelToIssueParams } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearAddLabelToIssueTool: ToolConfig< @@ -39,7 +40,7 @@ export const linearAddLabelToIssueTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/add_label_to_project.ts b/apps/sim/tools/linear/add_label_to_project.ts index 54408b2f46c..e2af0766e65 100644 --- a/apps/sim/tools/linear/add_label_to_project.ts +++ b/apps/sim/tools/linear/add_label_to_project.ts @@ -2,6 +2,7 @@ import type { LinearAddLabelToProjectParams, LinearAddLabelToProjectResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearAddLabelToProjectTool: ToolConfig< @@ -42,7 +43,7 @@ export const linearAddLabelToProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/archive_issue.ts b/apps/sim/tools/linear/archive_issue.ts index a166ee76a63..fb8128f51a7 100644 --- a/apps/sim/tools/linear/archive_issue.ts +++ b/apps/sim/tools/linear/archive_issue.ts @@ -1,4 +1,5 @@ import type { LinearArchiveIssueParams, LinearArchiveIssueResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearArchiveIssueTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearArchiveIssueTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/archive_label.ts b/apps/sim/tools/linear/archive_label.ts index e24250b6e22..0f9f9fa3e89 100644 --- a/apps/sim/tools/linear/archive_label.ts +++ b/apps/sim/tools/linear/archive_label.ts @@ -1,4 +1,5 @@ import type { LinearArchiveLabelParams, LinearArchiveLabelResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearArchiveLabelTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearArchiveLabelTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/archive_project.ts b/apps/sim/tools/linear/archive_project.ts index b7d322b668c..a45e20542b7 100644 --- a/apps/sim/tools/linear/archive_project.ts +++ b/apps/sim/tools/linear/archive_project.ts @@ -1,4 +1,5 @@ import type { LinearArchiveProjectParams, LinearArchiveProjectResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearArchiveProjectTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearArchiveProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/create_attachment.ts b/apps/sim/tools/linear/create_attachment.ts index 03019de9122..1965fd68895 100644 --- a/apps/sim/tools/linear/create_attachment.ts +++ b/apps/sim/tools/linear/create_attachment.ts @@ -3,6 +3,7 @@ import type { LinearCreateAttachmentResponse, } from '@/tools/linear/types' import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateAttachmentTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearCreateAttachmentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_comment.ts b/apps/sim/tools/linear/create_comment.ts index 966c9bad2ff..52417ae37d3 100644 --- a/apps/sim/tools/linear/create_comment.ts +++ b/apps/sim/tools/linear/create_comment.ts @@ -1,5 +1,6 @@ import type { LinearCreateCommentParams, LinearCreateCommentResponse } from '@/tools/linear/types' import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCommentTool: ToolConfig< @@ -40,7 +41,7 @@ export const linearCreateCommentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/create_customer.ts b/apps/sim/tools/linear/create_customer.ts index 241a9c8dce1..bc249eb4356 100644 --- a/apps/sim/tools/linear/create_customer.ts +++ b/apps/sim/tools/linear/create_customer.ts @@ -1,5 +1,6 @@ import type { LinearCreateCustomerParams, LinearCreateCustomerResponse } from '@/tools/linear/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCustomerTool: ToolConfig< @@ -82,7 +83,7 @@ export const linearCreateCustomerTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_customer_request.ts b/apps/sim/tools/linear/create_customer_request.ts index e07c9395864..2414440241c 100644 --- a/apps/sim/tools/linear/create_customer_request.ts +++ b/apps/sim/tools/linear/create_customer_request.ts @@ -2,6 +2,7 @@ import type { LinearCreateCustomerRequestParams, LinearCreateCustomerRequestResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCustomerRequestTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearCreateCustomerRequestTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_customer_status.ts b/apps/sim/tools/linear/create_customer_status.ts index 5e1467d5ec8..fb22480727f 100644 --- a/apps/sim/tools/linear/create_customer_status.ts +++ b/apps/sim/tools/linear/create_customer_status.ts @@ -3,6 +3,7 @@ import type { LinearCreateCustomerStatusResponse, } from '@/tools/linear/types' import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCustomerStatusTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearCreateCustomerStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_customer_tier.ts b/apps/sim/tools/linear/create_customer_tier.ts index bdb0df977c8..5335c509db6 100644 --- a/apps/sim/tools/linear/create_customer_tier.ts +++ b/apps/sim/tools/linear/create_customer_tier.ts @@ -3,6 +3,7 @@ import type { LinearCreateCustomerTierResponse, } from '@/tools/linear/types' import { CUSTOMER_TIER_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCustomerTierTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearCreateCustomerTierTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_cycle.ts b/apps/sim/tools/linear/create_cycle.ts index da3d67b5544..04e23390ba3 100644 --- a/apps/sim/tools/linear/create_cycle.ts +++ b/apps/sim/tools/linear/create_cycle.ts @@ -1,5 +1,6 @@ import type { LinearCreateCycleParams, LinearCreateCycleResponse } from '@/tools/linear/types' import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateCycleTool: ToolConfig = @@ -50,7 +51,7 @@ export const linearCreateCycleTool: ToolConfig { diff --git a/apps/sim/tools/linear/create_favorite.ts b/apps/sim/tools/linear/create_favorite.ts index 429357e2e3a..27efdf50aca 100644 --- a/apps/sim/tools/linear/create_favorite.ts +++ b/apps/sim/tools/linear/create_favorite.ts @@ -1,4 +1,5 @@ import type { LinearCreateFavoriteParams, LinearCreateFavoriteResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateFavoriteTool: ToolConfig< @@ -51,7 +52,7 @@ export const linearCreateFavoriteTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index adfc1af73b4..baff55786fc 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -1,5 +1,6 @@ import type { LinearCreateIssueParams, LinearCreateIssueResponse } from '@/tools/linear/types' import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateIssueTool: ToolConfig = @@ -110,7 +111,7 @@ export const linearCreateIssueTool: ToolConfig { diff --git a/apps/sim/tools/linear/create_issue_relation.ts b/apps/sim/tools/linear/create_issue_relation.ts index 82dfc8292f0..6d7d037be90 100644 --- a/apps/sim/tools/linear/create_issue_relation.ts +++ b/apps/sim/tools/linear/create_issue_relation.ts @@ -2,6 +2,7 @@ import type { LinearCreateIssueRelationParams, LinearCreateIssueRelationResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateIssueRelationTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearCreateIssueRelationTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/create_label.ts b/apps/sim/tools/linear/create_label.ts index 3ca63a52b1d..760da7301fa 100644 --- a/apps/sim/tools/linear/create_label.ts +++ b/apps/sim/tools/linear/create_label.ts @@ -1,5 +1,6 @@ import type { LinearCreateLabelParams, LinearCreateLabelResponse } from '@/tools/linear/types' import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateLabelTool: ToolConfig = @@ -50,7 +51,7 @@ export const linearCreateLabelTool: ToolConfig { diff --git a/apps/sim/tools/linear/create_project.ts b/apps/sim/tools/linear/create_project.ts index b42c676eb9e..eeabd85cb58 100644 --- a/apps/sim/tools/linear/create_project.ts +++ b/apps/sim/tools/linear/create_project.ts @@ -1,5 +1,6 @@ import type { LinearCreateProjectParams, LinearCreateProjectResponse } from '@/tools/linear/types' import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateProjectTool: ToolConfig< @@ -70,7 +71,7 @@ export const linearCreateProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_project_label.ts b/apps/sim/tools/linear/create_project_label.ts index 6ad108f310c..3b3e2447afc 100644 --- a/apps/sim/tools/linear/create_project_label.ts +++ b/apps/sim/tools/linear/create_project_label.ts @@ -3,6 +3,7 @@ import type { LinearCreateProjectLabelResponse, } from '@/tools/linear/types' import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateProjectLabelTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearCreateProjectLabelTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_project_milestone.ts b/apps/sim/tools/linear/create_project_milestone.ts index 022f8e13bcf..49c8e24666e 100644 --- a/apps/sim/tools/linear/create_project_milestone.ts +++ b/apps/sim/tools/linear/create_project_milestone.ts @@ -3,6 +3,7 @@ import type { LinearCreateProjectMilestoneResponse, } from '@/tools/linear/types' import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateProjectMilestoneTool: ToolConfig< @@ -55,7 +56,7 @@ export const linearCreateProjectMilestoneTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_project_status.ts b/apps/sim/tools/linear/create_project_status.ts index 5f63184daaf..e3d9cc92afd 100644 --- a/apps/sim/tools/linear/create_project_status.ts +++ b/apps/sim/tools/linear/create_project_status.ts @@ -3,6 +3,7 @@ import type { LinearCreateProjectStatusResponse, } from '@/tools/linear/types' import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateProjectStatusTool: ToolConfig< @@ -68,7 +69,7 @@ export const linearCreateProjectStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_project_update.ts b/apps/sim/tools/linear/create_project_update.ts index f6ce64f9260..1d2de1bda1b 100644 --- a/apps/sim/tools/linear/create_project_update.ts +++ b/apps/sim/tools/linear/create_project_update.ts @@ -2,6 +2,7 @@ import type { LinearCreateProjectUpdateParams, LinearCreateProjectUpdateResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateProjectUpdateTool: ToolConfig< @@ -48,7 +49,7 @@ export const linearCreateProjectUpdateTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/create_workflow_state.ts b/apps/sim/tools/linear/create_workflow_state.ts index 314dc748204..6a4f8b6d444 100644 --- a/apps/sim/tools/linear/create_workflow_state.ts +++ b/apps/sim/tools/linear/create_workflow_state.ts @@ -3,6 +3,7 @@ import type { LinearCreateWorkflowStateResponse, } from '@/tools/linear/types' import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearCreateWorkflowStateTool: ToolConfig< @@ -67,7 +68,7 @@ export const linearCreateWorkflowStateTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/delete_attachment.ts b/apps/sim/tools/linear/delete_attachment.ts index 44043c741e4..350174c0e44 100644 --- a/apps/sim/tools/linear/delete_attachment.ts +++ b/apps/sim/tools/linear/delete_attachment.ts @@ -2,6 +2,7 @@ import type { LinearDeleteAttachmentParams, LinearDeleteAttachmentResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteAttachmentTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteAttachmentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_comment.ts b/apps/sim/tools/linear/delete_comment.ts index 92c8acebb27..9d82c67b6f4 100644 --- a/apps/sim/tools/linear/delete_comment.ts +++ b/apps/sim/tools/linear/delete_comment.ts @@ -1,4 +1,5 @@ import type { LinearDeleteCommentParams, LinearDeleteCommentResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteCommentTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearDeleteCommentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_customer.ts b/apps/sim/tools/linear/delete_customer.ts index 9e66d1dd81c..dea9eb028cc 100644 --- a/apps/sim/tools/linear/delete_customer.ts +++ b/apps/sim/tools/linear/delete_customer.ts @@ -1,4 +1,5 @@ import type { LinearDeleteCustomerParams, LinearDeleteCustomerResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteCustomerTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearDeleteCustomerTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_customer_status.ts b/apps/sim/tools/linear/delete_customer_status.ts index 3e3beaf9a07..e461a54d8d8 100644 --- a/apps/sim/tools/linear/delete_customer_status.ts +++ b/apps/sim/tools/linear/delete_customer_status.ts @@ -2,6 +2,7 @@ import type { LinearDeleteCustomerStatusParams, LinearDeleteCustomerStatusResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteCustomerStatusTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteCustomerStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_customer_tier.ts b/apps/sim/tools/linear/delete_customer_tier.ts index d9d36183eeb..6e75df59186 100644 --- a/apps/sim/tools/linear/delete_customer_tier.ts +++ b/apps/sim/tools/linear/delete_customer_tier.ts @@ -2,6 +2,7 @@ import type { LinearDeleteCustomerTierParams, LinearDeleteCustomerTierResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteCustomerTierTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteCustomerTierTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_issue.ts b/apps/sim/tools/linear/delete_issue.ts index 1e558666e64..773f9dd8c98 100644 --- a/apps/sim/tools/linear/delete_issue.ts +++ b/apps/sim/tools/linear/delete_issue.ts @@ -1,4 +1,5 @@ import type { LinearDeleteIssueParams, LinearDeleteIssueResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteIssueTool: ToolConfig = @@ -31,7 +32,7 @@ export const linearDeleteIssueTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/delete_issue_relation.ts b/apps/sim/tools/linear/delete_issue_relation.ts index 5ebb8708b78..faea68134cd 100644 --- a/apps/sim/tools/linear/delete_issue_relation.ts +++ b/apps/sim/tools/linear/delete_issue_relation.ts @@ -2,6 +2,7 @@ import type { LinearDeleteIssueRelationParams, LinearDeleteIssueRelationResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteIssueRelationTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteIssueRelationTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_project.ts b/apps/sim/tools/linear/delete_project.ts index 24f49c0b409..a993401dba5 100644 --- a/apps/sim/tools/linear/delete_project.ts +++ b/apps/sim/tools/linear/delete_project.ts @@ -1,4 +1,5 @@ import type { LinearDeleteProjectParams, LinearDeleteProjectResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteProjectTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearDeleteProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_project_label.ts b/apps/sim/tools/linear/delete_project_label.ts index a4bec4e2a40..31384e8858c 100644 --- a/apps/sim/tools/linear/delete_project_label.ts +++ b/apps/sim/tools/linear/delete_project_label.ts @@ -2,6 +2,7 @@ import type { LinearDeleteProjectLabelParams, LinearDeleteProjectLabelResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteProjectLabelTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteProjectLabelTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_project_milestone.ts b/apps/sim/tools/linear/delete_project_milestone.ts index 43637409fc1..7089f99235e 100644 --- a/apps/sim/tools/linear/delete_project_milestone.ts +++ b/apps/sim/tools/linear/delete_project_milestone.ts @@ -2,6 +2,7 @@ import type { LinearDeleteProjectMilestoneParams, LinearDeleteProjectMilestoneResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteProjectMilestoneTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteProjectMilestoneTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/delete_project_status.ts b/apps/sim/tools/linear/delete_project_status.ts index 9785816344e..566c3c1fbc4 100644 --- a/apps/sim/tools/linear/delete_project_status.ts +++ b/apps/sim/tools/linear/delete_project_status.ts @@ -2,6 +2,7 @@ import type { LinearDeleteProjectStatusParams, LinearDeleteProjectStatusResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearDeleteProjectStatusTool: ToolConfig< @@ -36,7 +37,7 @@ export const linearDeleteProjectStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/get_active_cycle.ts b/apps/sim/tools/linear/get_active_cycle.ts index eda62de1102..7c8a06ea523 100644 --- a/apps/sim/tools/linear/get_active_cycle.ts +++ b/apps/sim/tools/linear/get_active_cycle.ts @@ -1,5 +1,6 @@ import type { LinearGetActiveCycleParams, LinearGetActiveCycleResponse } from '@/tools/linear/types' import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetActiveCycleTool: ToolConfig< @@ -34,7 +35,7 @@ export const linearGetActiveCycleTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/get_customer.ts b/apps/sim/tools/linear/get_customer.ts index 10d241698c8..4641629179d 100644 --- a/apps/sim/tools/linear/get_customer.ts +++ b/apps/sim/tools/linear/get_customer.ts @@ -1,5 +1,6 @@ import type { LinearGetCustomerParams, LinearGetCustomerResponse } from '@/tools/linear/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetCustomerTool: ToolConfig = @@ -32,7 +33,7 @@ export const linearGetCustomerTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/get_cycle.ts b/apps/sim/tools/linear/get_cycle.ts index f5f9eb24083..d47184d4983 100644 --- a/apps/sim/tools/linear/get_cycle.ts +++ b/apps/sim/tools/linear/get_cycle.ts @@ -1,5 +1,6 @@ import type { LinearGetCycleParams, LinearGetCycleResponse } from '@/tools/linear/types' import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetCycleTool: ToolConfig = { @@ -31,7 +32,7 @@ export const linearGetCycleTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/get_issue.ts b/apps/sim/tools/linear/get_issue.ts index e5f354c7fa6..8f6840e99fa 100644 --- a/apps/sim/tools/linear/get_issue.ts +++ b/apps/sim/tools/linear/get_issue.ts @@ -1,5 +1,6 @@ import type { LinearGetIssueParams, LinearGetIssueResponse } from '@/tools/linear/types' import { ISSUE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetIssueTool: ToolConfig = { @@ -31,7 +32,7 @@ export const linearGetIssueTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/get_project.ts b/apps/sim/tools/linear/get_project.ts index 71690a6ba09..b1fef8bef15 100644 --- a/apps/sim/tools/linear/get_project.ts +++ b/apps/sim/tools/linear/get_project.ts @@ -1,5 +1,6 @@ import type { LinearGetProjectParams, LinearGetProjectResponse } from '@/tools/linear/types' import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetProjectTool: ToolConfig = { @@ -31,7 +32,7 @@ export const linearGetProjectTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/get_viewer.ts b/apps/sim/tools/linear/get_viewer.ts index faf5a13938f..9e9a88ee8e5 100644 --- a/apps/sim/tools/linear/get_viewer.ts +++ b/apps/sim/tools/linear/get_viewer.ts @@ -1,5 +1,6 @@ import type { LinearGetViewerParams, LinearGetViewerResponse } from '@/tools/linear/types' import { USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearGetViewerTool: ToolConfig = { @@ -24,7 +25,7 @@ export const linearGetViewerTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/list_attachments.ts b/apps/sim/tools/linear/list_attachments.ts index 0eac48b829b..074b3c4acd0 100644 --- a/apps/sim/tools/linear/list_attachments.ts +++ b/apps/sim/tools/linear/list_attachments.ts @@ -3,6 +3,7 @@ import type { LinearListAttachmentsResponse, } from '@/tools/linear/types' import { ATTACHMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListAttachmentsTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearListAttachmentsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_comments.ts b/apps/sim/tools/linear/list_comments.ts index 05eb77103ae..a440ad8ec6d 100644 --- a/apps/sim/tools/linear/list_comments.ts +++ b/apps/sim/tools/linear/list_comments.ts @@ -1,5 +1,6 @@ import type { LinearListCommentsParams, LinearListCommentsResponse } from '@/tools/linear/types' import { COMMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCommentsTool: ToolConfig< @@ -46,7 +47,7 @@ export const linearListCommentsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_customer_requests.ts b/apps/sim/tools/linear/list_customer_requests.ts index abf406e7680..35a1830ddae 100644 --- a/apps/sim/tools/linear/list_customer_requests.ts +++ b/apps/sim/tools/linear/list_customer_requests.ts @@ -2,6 +2,7 @@ import type { LinearListCustomerRequestsParams, LinearListCustomerRequestsResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCustomerRequestsTool: ToolConfig< @@ -48,7 +49,7 @@ export const linearListCustomerRequestsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_customer_statuses.ts b/apps/sim/tools/linear/list_customer_statuses.ts index b7f3ea7d76b..da3bf26c376 100644 --- a/apps/sim/tools/linear/list_customer_statuses.ts +++ b/apps/sim/tools/linear/list_customer_statuses.ts @@ -3,6 +3,7 @@ import type { LinearListCustomerStatusesResponse, } from '@/tools/linear/types' import { CUSTOMER_STATUS_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCustomerStatusesTool: ToolConfig< @@ -43,7 +44,7 @@ export const linearListCustomerStatusesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_customer_tiers.ts b/apps/sim/tools/linear/list_customer_tiers.ts index cecfe9e653c..ab6f7cccffe 100644 --- a/apps/sim/tools/linear/list_customer_tiers.ts +++ b/apps/sim/tools/linear/list_customer_tiers.ts @@ -3,6 +3,7 @@ import type { LinearListCustomerTiersResponse, } from '@/tools/linear/types' import { CUSTOMER_TIER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCustomerTiersTool: ToolConfig< @@ -43,7 +44,7 @@ export const linearListCustomerTiersTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_customers.ts b/apps/sim/tools/linear/list_customers.ts index 09db5ab6499..a4e5bc66f4c 100644 --- a/apps/sim/tools/linear/list_customers.ts +++ b/apps/sim/tools/linear/list_customers.ts @@ -1,5 +1,6 @@ import type { LinearListCustomersParams, LinearListCustomersResponse } from '@/tools/linear/types' import { CUSTOMER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCustomersTool: ToolConfig< @@ -46,7 +47,7 @@ export const linearListCustomersTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_cycles.ts b/apps/sim/tools/linear/list_cycles.ts index 5a3ee3d078c..304b7cc38e2 100644 --- a/apps/sim/tools/linear/list_cycles.ts +++ b/apps/sim/tools/linear/list_cycles.ts @@ -1,5 +1,6 @@ import type { LinearListCyclesParams, LinearListCyclesResponse } from '@/tools/linear/types' import { CYCLE_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListCyclesTool: ToolConfig = { @@ -43,7 +44,7 @@ export const linearListCyclesTool: ToolConfig { diff --git a/apps/sim/tools/linear/list_favorites.ts b/apps/sim/tools/linear/list_favorites.ts index 79e6cd0b01d..a778ef4bec0 100644 --- a/apps/sim/tools/linear/list_favorites.ts +++ b/apps/sim/tools/linear/list_favorites.ts @@ -1,4 +1,5 @@ import type { LinearListFavoritesParams, LinearListFavoritesResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListFavoritesTool: ToolConfig< @@ -39,7 +40,7 @@ export const linearListFavoritesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_issue_relations.ts b/apps/sim/tools/linear/list_issue_relations.ts index b008ba45fc4..23035e2b2dd 100644 --- a/apps/sim/tools/linear/list_issue_relations.ts +++ b/apps/sim/tools/linear/list_issue_relations.ts @@ -2,6 +2,7 @@ import type { LinearListIssueRelationsParams, LinearListIssueRelationsResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListIssueRelationsTool: ToolConfig< @@ -48,7 +49,7 @@ export const linearListIssueRelationsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_labels.ts b/apps/sim/tools/linear/list_labels.ts index ec1f66e375f..b4fceec910a 100644 --- a/apps/sim/tools/linear/list_labels.ts +++ b/apps/sim/tools/linear/list_labels.ts @@ -1,5 +1,6 @@ import type { LinearListLabelsParams, LinearListLabelsResponse } from '@/tools/linear/types' import { LABEL_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListLabelsTool: ToolConfig = { @@ -43,7 +44,7 @@ export const linearListLabelsTool: ToolConfig { diff --git a/apps/sim/tools/linear/list_notifications.ts b/apps/sim/tools/linear/list_notifications.ts index 7509334c4fa..b09a1ae7bab 100644 --- a/apps/sim/tools/linear/list_notifications.ts +++ b/apps/sim/tools/linear/list_notifications.ts @@ -2,6 +2,7 @@ import type { LinearListNotificationsParams, LinearListNotificationsResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListNotificationsTool: ToolConfig< @@ -42,7 +43,7 @@ export const linearListNotificationsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_project_labels.ts b/apps/sim/tools/linear/list_project_labels.ts index 5156572b817..c98dac175e4 100644 --- a/apps/sim/tools/linear/list_project_labels.ts +++ b/apps/sim/tools/linear/list_project_labels.ts @@ -3,6 +3,7 @@ import type { LinearListProjectLabelsResponse, } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListProjectLabelsTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearListProjectLabelsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/list_project_milestones.ts b/apps/sim/tools/linear/list_project_milestones.ts index 59e9bd2cb40..3039a407c43 100644 --- a/apps/sim/tools/linear/list_project_milestones.ts +++ b/apps/sim/tools/linear/list_project_milestones.ts @@ -3,6 +3,7 @@ import type { LinearListProjectMilestonesResponse, } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListProjectMilestonesTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearListProjectMilestonesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_project_statuses.ts b/apps/sim/tools/linear/list_project_statuses.ts index b0b1efb1240..455f33500ca 100644 --- a/apps/sim/tools/linear/list_project_statuses.ts +++ b/apps/sim/tools/linear/list_project_statuses.ts @@ -3,6 +3,7 @@ import type { LinearListProjectStatusesResponse, } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListProjectStatusesTool: ToolConfig< @@ -43,7 +44,7 @@ export const linearListProjectStatusesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_project_updates.ts b/apps/sim/tools/linear/list_project_updates.ts index 2b76d92ab94..bf2a0a5a265 100644 --- a/apps/sim/tools/linear/list_project_updates.ts +++ b/apps/sim/tools/linear/list_project_updates.ts @@ -2,6 +2,7 @@ import type { LinearListProjectUpdatesParams, LinearListProjectUpdatesResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListProjectUpdatesTool: ToolConfig< @@ -48,7 +49,7 @@ export const linearListProjectUpdatesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/list_projects.ts b/apps/sim/tools/linear/list_projects.ts index 2af440f53a3..4ebbcb3e0b9 100644 --- a/apps/sim/tools/linear/list_projects.ts +++ b/apps/sim/tools/linear/list_projects.ts @@ -1,5 +1,6 @@ import type { LinearListProjectsParams, LinearListProjectsResponse } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListProjectsTool: ToolConfig< @@ -52,7 +53,7 @@ export const linearListProjectsTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/list_teams.ts b/apps/sim/tools/linear/list_teams.ts index 268586ae7db..a695ff63f93 100644 --- a/apps/sim/tools/linear/list_teams.ts +++ b/apps/sim/tools/linear/list_teams.ts @@ -1,5 +1,6 @@ import type { LinearListTeamsParams, LinearListTeamsResponse } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, TEAM_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListTeamsTool: ToolConfig = { @@ -37,7 +38,7 @@ export const linearListTeamsTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/list_users.ts b/apps/sim/tools/linear/list_users.ts index 00dace61086..f6378d39ea0 100644 --- a/apps/sim/tools/linear/list_users.ts +++ b/apps/sim/tools/linear/list_users.ts @@ -1,5 +1,6 @@ import type { LinearListUsersParams, LinearListUsersResponse } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListUsersTool: ToolConfig = { @@ -43,7 +44,7 @@ export const linearListUsersTool: ToolConfig ({ diff --git a/apps/sim/tools/linear/list_workflow_states.ts b/apps/sim/tools/linear/list_workflow_states.ts index cf95be8e1cc..87c0f36c210 100644 --- a/apps/sim/tools/linear/list_workflow_states.ts +++ b/apps/sim/tools/linear/list_workflow_states.ts @@ -3,6 +3,7 @@ import type { LinearListWorkflowStatesResponse, } from '@/tools/linear/types' import { PAGE_INFO_OUTPUT, WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearListWorkflowStatesTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearListWorkflowStatesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/merge_customers.ts b/apps/sim/tools/linear/merge_customers.ts index 6ed745d0687..d761df08c2c 100644 --- a/apps/sim/tools/linear/merge_customers.ts +++ b/apps/sim/tools/linear/merge_customers.ts @@ -1,4 +1,5 @@ import type { LinearMergeCustomersParams, LinearMergeCustomersResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearMergeCustomersTool: ToolConfig< @@ -39,7 +40,7 @@ export const linearMergeCustomersTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts index 3dd443d12b7..fabe39c6af8 100644 --- a/apps/sim/tools/linear/read_issues.ts +++ b/apps/sim/tools/linear/read_issues.ts @@ -1,5 +1,6 @@ import type { LinearReadIssuesParams, LinearReadIssuesResponse } from '@/tools/linear/types' import { ISSUE_LIST_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearReadIssuesTool: ToolConfig = { @@ -97,7 +98,7 @@ export const linearReadIssuesTool: ToolConfig { diff --git a/apps/sim/tools/linear/remove_label_from_issue.ts b/apps/sim/tools/linear/remove_label_from_issue.ts index 262ce31905a..202d179dfae 100644 --- a/apps/sim/tools/linear/remove_label_from_issue.ts +++ b/apps/sim/tools/linear/remove_label_from_issue.ts @@ -2,6 +2,7 @@ import type { LinearRemoveLabelFromIssueParams, LinearRemoveLabelResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearRemoveLabelFromIssueTool: ToolConfig< @@ -42,7 +43,7 @@ export const linearRemoveLabelFromIssueTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/remove_label_from_project.ts b/apps/sim/tools/linear/remove_label_from_project.ts index 278cfff393c..dd725782953 100644 --- a/apps/sim/tools/linear/remove_label_from_project.ts +++ b/apps/sim/tools/linear/remove_label_from_project.ts @@ -2,6 +2,7 @@ import type { LinearRemoveLabelFromProjectParams, LinearRemoveLabelFromProjectResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearRemoveLabelFromProjectTool: ToolConfig< @@ -42,7 +43,7 @@ export const linearRemoveLabelFromProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/search_issues.ts b/apps/sim/tools/linear/search_issues.ts index 05efaa59118..9bda838eece 100644 --- a/apps/sim/tools/linear/search_issues.ts +++ b/apps/sim/tools/linear/search_issues.ts @@ -1,5 +1,6 @@ import type { LinearSearchIssuesParams, LinearSearchIssuesResponse } from '@/tools/linear/types' import { ISSUE_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearSearchIssuesTool: ToolConfig< @@ -58,7 +59,7 @@ export const linearSearchIssuesTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/unarchive_issue.ts b/apps/sim/tools/linear/unarchive_issue.ts index 111b86d1e0f..bf46cbeee7d 100644 --- a/apps/sim/tools/linear/unarchive_issue.ts +++ b/apps/sim/tools/linear/unarchive_issue.ts @@ -1,4 +1,5 @@ import type { LinearUnarchiveIssueParams, LinearUnarchiveIssueResponse } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUnarchiveIssueTool: ToolConfig< @@ -33,7 +34,7 @@ export const linearUnarchiveIssueTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => ({ diff --git a/apps/sim/tools/linear/update_attachment.ts b/apps/sim/tools/linear/update_attachment.ts index 4afbe56bb41..f89d872048e 100644 --- a/apps/sim/tools/linear/update_attachment.ts +++ b/apps/sim/tools/linear/update_attachment.ts @@ -3,6 +3,7 @@ import type { LinearUpdateAttachmentResponse, } from '@/tools/linear/types' import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateAttachmentTool: ToolConfig< @@ -49,7 +50,7 @@ export const linearUpdateAttachmentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_comment.ts b/apps/sim/tools/linear/update_comment.ts index 67249d393ec..6102d5a27eb 100644 --- a/apps/sim/tools/linear/update_comment.ts +++ b/apps/sim/tools/linear/update_comment.ts @@ -1,5 +1,6 @@ import type { LinearUpdateCommentParams, LinearUpdateCommentResponse } from '@/tools/linear/types' import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateCommentTool: ToolConfig< @@ -40,7 +41,7 @@ export const linearUpdateCommentTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_customer.ts b/apps/sim/tools/linear/update_customer.ts index e635d5f29c6..1e5f21f697b 100644 --- a/apps/sim/tools/linear/update_customer.ts +++ b/apps/sim/tools/linear/update_customer.ts @@ -1,5 +1,6 @@ import type { LinearUpdateCustomerParams, LinearUpdateCustomerResponse } from '@/tools/linear/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateCustomerTool: ToolConfig< @@ -88,7 +89,7 @@ export const linearUpdateCustomerTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_customer_request.ts b/apps/sim/tools/linear/update_customer_request.ts index 317ddf315fe..ad380abf66d 100644 --- a/apps/sim/tools/linear/update_customer_request.ts +++ b/apps/sim/tools/linear/update_customer_request.ts @@ -2,6 +2,7 @@ import type { LinearUpdateCustomerRequestParams, LinearUpdateCustomerRequestResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateCustomerRequestTool: ToolConfig< @@ -67,7 +68,7 @@ export const linearUpdateCustomerRequestTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_customer_status.ts b/apps/sim/tools/linear/update_customer_status.ts index 290ceedd339..7a30933330d 100644 --- a/apps/sim/tools/linear/update_customer_status.ts +++ b/apps/sim/tools/linear/update_customer_status.ts @@ -3,6 +3,7 @@ import type { LinearUpdateCustomerStatusResponse, } from '@/tools/linear/types' import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateCustomerStatusTool: ToolConfig< @@ -67,7 +68,7 @@ export const linearUpdateCustomerStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_customer_tier.ts b/apps/sim/tools/linear/update_customer_tier.ts index 62f5c2c9c3e..de72c7e501c 100644 --- a/apps/sim/tools/linear/update_customer_tier.ts +++ b/apps/sim/tools/linear/update_customer_tier.ts @@ -2,6 +2,7 @@ import type { LinearUpdateCustomerTierParams, LinearUpdateCustomerTierResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateCustomerTierTool: ToolConfig< @@ -66,7 +67,7 @@ export const linearUpdateCustomerTierTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_issue.ts b/apps/sim/tools/linear/update_issue.ts index 2b98eaa5c16..5f7053d712b 100644 --- a/apps/sim/tools/linear/update_issue.ts +++ b/apps/sim/tools/linear/update_issue.ts @@ -1,5 +1,6 @@ import type { LinearUpdateIssueParams, LinearUpdateIssueResponse } from '@/tools/linear/types' import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateIssueTool: ToolConfig = @@ -110,7 +111,7 @@ export const linearUpdateIssueTool: ToolConfig { diff --git a/apps/sim/tools/linear/update_label.ts b/apps/sim/tools/linear/update_label.ts index ea34b08820f..d7edc5e9112 100644 --- a/apps/sim/tools/linear/update_label.ts +++ b/apps/sim/tools/linear/update_label.ts @@ -1,5 +1,6 @@ import type { LinearUpdateLabelParams, LinearUpdateLabelResponse } from '@/tools/linear/types' import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateLabelTool: ToolConfig = @@ -50,7 +51,7 @@ export const linearUpdateLabelTool: ToolConfig { diff --git a/apps/sim/tools/linear/update_notification.ts b/apps/sim/tools/linear/update_notification.ts index b297682141d..a298f2f0cc5 100644 --- a/apps/sim/tools/linear/update_notification.ts +++ b/apps/sim/tools/linear/update_notification.ts @@ -2,6 +2,7 @@ import type { LinearUpdateNotificationParams, LinearUpdateNotificationResponse, } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateNotificationTool: ToolConfig< @@ -42,7 +43,7 @@ export const linearUpdateNotificationTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_project.ts b/apps/sim/tools/linear/update_project.ts index 10a65d3320b..31ca7a9529a 100644 --- a/apps/sim/tools/linear/update_project.ts +++ b/apps/sim/tools/linear/update_project.ts @@ -1,5 +1,6 @@ import type { LinearUpdateProjectParams, LinearUpdateProjectResponse } from '@/tools/linear/types' import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateProjectTool: ToolConfig< @@ -76,7 +77,7 @@ export const linearUpdateProjectTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_project_label.ts b/apps/sim/tools/linear/update_project_label.ts index 51932a54efe..804ffae7b6c 100644 --- a/apps/sim/tools/linear/update_project_label.ts +++ b/apps/sim/tools/linear/update_project_label.ts @@ -3,6 +3,7 @@ import type { LinearUpdateProjectLabelResponse, } from '@/tools/linear/types' import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateProjectLabelTool: ToolConfig< @@ -55,7 +56,7 @@ export const linearUpdateProjectLabelTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_project_milestone.ts b/apps/sim/tools/linear/update_project_milestone.ts index 59a09b6c521..528f4754fd0 100644 --- a/apps/sim/tools/linear/update_project_milestone.ts +++ b/apps/sim/tools/linear/update_project_milestone.ts @@ -3,6 +3,7 @@ import type { LinearUpdateProjectMilestoneResponse, } from '@/tools/linear/types' import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateProjectMilestoneTool: ToolConfig< @@ -55,7 +56,7 @@ export const linearUpdateProjectMilestoneTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_project_status.ts b/apps/sim/tools/linear/update_project_status.ts index c6688d91074..63d90185625 100644 --- a/apps/sim/tools/linear/update_project_status.ts +++ b/apps/sim/tools/linear/update_project_status.ts @@ -3,6 +3,7 @@ import type { LinearUpdateProjectStatusResponse, } from '@/tools/linear/types' import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateProjectStatusTool: ToolConfig< @@ -67,7 +68,7 @@ export const linearUpdateProjectStatusTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/update_workflow_state.ts b/apps/sim/tools/linear/update_workflow_state.ts index 536e2213aae..a27ffa87835 100644 --- a/apps/sim/tools/linear/update_workflow_state.ts +++ b/apps/sim/tools/linear/update_workflow_state.ts @@ -3,6 +3,7 @@ import type { LinearUpdateWorkflowStateResponse, } from '@/tools/linear/types' import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types' +import { linearAuthorizationHeader } from '@/tools/linear/utils' import type { ToolConfig } from '@/tools/types' export const linearUpdateWorkflowStateTool: ToolConfig< @@ -61,7 +62,7 @@ export const linearUpdateWorkflowStateTool: ToolConfig< } return { 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken}`, + Authorization: linearAuthorizationHeader(params.accessToken), } }, body: (params) => { diff --git a/apps/sim/tools/linear/utils.test.ts b/apps/sim/tools/linear/utils.test.ts new file mode 100644 index 00000000000..18f8fc709de --- /dev/null +++ b/apps/sim/tools/linear/utils.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { linearAuthorizationHeader } from '@/tools/linear/utils' + +describe('linearAuthorizationHeader', () => { + it('returns a personal API key bare without the Bearer scheme', () => { + expect(linearAuthorizationHeader('lin_api_abc123')).toBe('lin_api_abc123') + }) + + it('returns OAuth access tokens with the Bearer scheme', () => { + expect(linearAuthorizationHeader('lin_oauth_xyz789')).toBe('Bearer lin_oauth_xyz789') + expect(linearAuthorizationHeader('some-opaque-token')).toBe('Bearer some-opaque-token') + }) +}) diff --git a/apps/sim/tools/linear/utils.ts b/apps/sim/tools/linear/utils.ts new file mode 100644 index 00000000000..6a07107b9f8 --- /dev/null +++ b/apps/sim/tools/linear/utils.ts @@ -0,0 +1,15 @@ +/** + * Builds the Authorization header value for Linear API requests. + * + * Linear documents two credential shapes: OAuth access tokens are sent as + * `Authorization: Bearer `, while personal API keys (prefixed with + * `lin_api_`) must be sent bare as `Authorization: ` with no scheme. + * This helper detects the personal-key prefix and returns the correct form + * so tools work with both OAuth connections and pasted API keys. + * + * @param accessToken - OAuth access token or Linear personal API key + * @returns The value to use for the `Authorization` header + */ +export function linearAuthorizationHeader(accessToken: string): string { + return accessToken.startsWith('lin_api_') ? accessToken : `Bearer ${accessToken}` +} From 68c965dd7167e6c1c13d84d905df5f5045977a58 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 17:42:53 -0700 Subject: [PATCH 02/10] 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 --- apps/sim/app/api/auth/oauth/utils.ts | 52 +++- apps/sim/lib/api/contracts/credentials.ts | 61 +--- .../lib/credentials/service-account-fields.ts | 46 +++ .../lib/credentials/service-account-secret.ts | 264 +++++++++++------- .../token-service-accounts/descriptors.ts | 4 +- .../token-service-accounts/errors.ts | 37 +++ .../validators/airtable.test.ts | 10 + .../validators/airtable.ts | 20 +- .../validators/asana.test.ts | 10 + .../validators/asana.ts | 18 +- .../validators/attio.test.ts | 10 + .../validators/attio.ts | 24 +- .../validators/calcom.test.ts | 15 + .../validators/calcom.ts | 18 +- .../validators/hubspot.test.ts | 15 - .../validators/hubspot.ts | 62 ++-- .../validators/linear.test.ts | 85 ++++++ .../validators/linear.ts | 62 ++-- .../validators/monday.test.ts | 19 ++ .../validators/monday.ts | 52 +++- .../validators/notion.test.ts | 10 + .../validators/notion.ts | 22 +- .../validators/shopify.test.ts | 34 ++- .../validators/shopify.ts | 40 ++- .../validators/trello.test.ts | 41 ++- .../validators/trello.ts | 43 ++- .../validators/wealthbox.test.ts | 13 + .../validators/wealthbox.ts | 41 +-- .../validators/webflow.test.ts | 15 + .../validators/webflow.ts | 18 +- apps/sim/tools/shopify/adjust_inventory.ts | 2 +- apps/sim/tools/shopify/cancel_order.ts | 2 +- apps/sim/tools/shopify/create_customer.ts | 2 +- apps/sim/tools/shopify/create_fulfillment.ts | 2 +- apps/sim/tools/shopify/create_product.ts | 2 +- apps/sim/tools/shopify/delete_customer.ts | 2 +- apps/sim/tools/shopify/delete_product.ts | 2 +- apps/sim/tools/shopify/get_collection.ts | 2 +- apps/sim/tools/shopify/get_customer.ts | 2 +- apps/sim/tools/shopify/get_inventory_level.ts | 2 +- apps/sim/tools/shopify/get_order.ts | 2 +- apps/sim/tools/shopify/get_product.ts | 2 +- apps/sim/tools/shopify/list_collections.ts | 2 +- apps/sim/tools/shopify/list_customers.ts | 2 +- .../sim/tools/shopify/list_inventory_items.ts | 2 +- apps/sim/tools/shopify/list_locations.ts | 2 +- apps/sim/tools/shopify/list_orders.ts | 2 +- apps/sim/tools/shopify/list_products.ts | 2 +- apps/sim/tools/shopify/types.ts | 5 +- apps/sim/tools/shopify/update_customer.ts | 2 +- apps/sim/tools/shopify/update_order.ts | 2 +- apps/sim/tools/shopify/update_product.ts | 2 +- 52 files changed, 826 insertions(+), 382 deletions(-) create mode 100644 apps/sim/lib/credentials/service-account-fields.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index e5b2f314fab..9d539bcd39a 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -367,34 +367,56 @@ async function getTokenServiceAccountSecret( return parseTokenServiceAccountSecretBlob(decrypted, providerId) } -export async function resolveServiceAccountToken( - credentialId: string, - providerId: string | null | undefined, - scopes?: string[], +interface ServiceAccountTokenOptions { + scopes?: string[] impersonateEmail?: string -): Promise { - if (providerId && isTokenServiceAccountProviderId(providerId)) { - const secret = await getTokenServiceAccountSecret(credentialId, providerId) - return { accessToken: secret.apiToken, domain: secret.domain } - } - if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { +} + +type ServiceAccountTokenResolver = ( + credentialId: string, + options: ServiceAccountTokenOptions +) => Promise + +/** + * Resolver registry for the bespoke service-account providers. Token-paste + * providers (registered in `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`) resolve + * generically: the stored token IS the access token. + */ +const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = { + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => { const secret = await getAtlassianServiceAccountSecret(credentialId) return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } - } - if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + }, + [SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => { const botCredential = await getSlackBotCredential(credentialId) if (!botCredential) { throw new Error('Slack bot credential not found') } return { accessToken: botCredential.botToken } - } - if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + }, + [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, { scopes, impersonateEmail }) => { if (!scopes?.length) { throw new Error('Scopes are required for service account credentials') } return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) } + }, +} + +export async function resolveServiceAccountToken( + credentialId: string, + providerId: string | null | undefined, + scopes?: string[], + impersonateEmail?: string +): Promise { + if (providerId && isTokenServiceAccountProviderId(providerId)) { + const secret = await getTokenServiceAccountSecret(credentialId, providerId) + return { accessToken: secret.apiToken, domain: secret.domain } + } + const resolver = providerId ? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId] : undefined + if (!resolver) { + throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`) } - throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`) + return resolver(credentialId, { scopes, impersonateEmail }) } /** diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index aad45af4b64..22793dd27d5 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -1,11 +1,7 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' -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_]+$/ @@ -162,59 +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 - } - const tokenRequiredFields = TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS[data.providerId ?? ''] - if (tokenRequiredFields) { - for (const field of tokenRequiredFields) { - if (!data[field]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${field} is required for ${data.providerId} credentials`, - path: [field], - }) - } - } - return - } - if (!data.serviceAccountJson) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'serviceAccountJson is required for service account credentials', - path: ['serviceAccountJson'], - }) } return } diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts new file mode 100644 index 00000000000..992995da41e --- /dev/null +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -0,0 +1,46 @@ +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 single + * client-safe source of truth consumed by the `createCredentialBodySchema` + * superRefine and by field checks in `verifyAndBuildServiceAccountSecret`. + * Token-paste providers contribute their entries from + * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are + * declared here. + */ +export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { + [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['apiToken', 'domain'], + [SLACK_CUSTOM_BOT_PROVIDER_ID]: ['signingSecret', 'botToken'], + ...TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, +} + +/** + * Legacy Google creates may omit `providerId` entirely (the original + * service-account flow predates multi-provider support), so an unknown or + * missing provider falls back to requiring the Google JSON key. + */ +export const FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS: readonly ServiceAccountFieldId[] = [ + 'serviceAccountJson', +] + +export function getServiceAccountRequiredFields( + providerId: string | null | undefined +): readonly ServiceAccountFieldId[] { + return ( + SERVICE_ACCOUNT_REQUIRED_FIELDS[providerId ?? ''] ?? FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS + ) +} diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 605a3d25d81..39884e2f6f8 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -8,6 +8,7 @@ import { } from '@/lib/credentials/atlassian-service-account' import { getTokenServiceAccountDescriptor, + isTokenServiceAccountProviderId, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, } from '@/lib/credentials/token-service-accounts/descriptors' import { @@ -17,6 +18,7 @@ import { 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' @@ -50,118 +52,90 @@ export class ServiceAccountSecretError extends Error { } /** - * Verifies a service-account secret against its provider, derives the display - * name, and returns the encrypted blob ready to persist. Shared by credential - * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt - * identically. Throws {@link ServiceAccountSecretError} on missing fields or a - * failed provider verification (callers map it to a 400). + * Builds an Atlassian service-account secret (scoped API token + site domain). */ -export async function verifyAndBuildServiceAccountSecret( - providerId: string, +async function buildAtlassianServiceAccountSecret( fields: ServiceAccountSecretFields ): Promise { - if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { - const { apiToken, domain } = fields - if (!apiToken || !domain) { - throw new ServiceAccountSecretError( - 'apiToken and domain are required for Atlassian service account credentials' - ) - } - const normalizedDomain = normalizeAtlassianDomain(domain) - const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) - const blob = JSON.stringify({ - type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, - apiToken, - domain: normalizedDomain, - cloudId: validation.cloudId, - atlassianAccountId: validation.accountId, - }) - const { encrypted } = await encryptSecret(blob) - return { - providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - encryptedServiceAccountKey: encrypted, - displayName: validation.displayName, - auditMetadata: { - atlassianDomain: normalizedDomain, - atlassianCloudId: validation.cloudId, - }, - } + const { apiToken, domain } = fields + if (!apiToken || !domain) { + throw new ServiceAccountSecretError( + 'apiToken and domain are required for Atlassian service account credentials' + ) } - - 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, + }, } +} - const tokenDescriptor = getTokenServiceAccountDescriptor(providerId) - if (tokenDescriptor) { - const apiToken = fields.apiToken?.trim() - const domain = fields.domain?.trim() - const requiresDomain = tokenDescriptor.fields.some((field) => field.id === 'domain') - if (!apiToken || (requiresDomain && !domain)) { - const required = tokenDescriptor.fields.map((field) => field.id).join(' and ') - throw new ServiceAccountSecretError( - `${required} ${tokenDescriptor.fields.length > 1 ? 'are' : 'is'} required for ${tokenDescriptor.serviceLabel} service account credentials` - ) - } - const validator = getTokenServiceAccountValidator(providerId) - if (!validator) { - throw new ServiceAccountSecretError( - `No validator registered for service-account provider ${providerId}` - ) - } - 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, - } +/** + * Builds a custom Slack bot secret. The workspace/team identity is derived via + * `auth.test` and never trusted from the client. + */ +async function buildSlackCustomBotSecret( + fields: ServiceAccountSecretFields +): Promise { + const { signingSecret, botToken } = fields + if (!signingSecret || !botToken) { + throw new ServiceAccountSecretError( + 'signingSecret and botToken are required for a custom Slack bot credential' + ) + } + let teamId: string + let botUserId: string | undefined + let teamName: string | undefined + try { + const auth = await fetchSlackTeamId(botToken) + teamId = auth.teamId + botUserId = auth.userId + teamName = auth.teamName + } catch (error) { + throw new ServiceAccountSecretError( + `Could not verify the Slack bot token: ${getErrorMessage(error)}` + ) } + const blob = JSON.stringify({ + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + signingSecret, + botToken, + teamId, + botUserId, + teamName, + }) + const { encrypted } = await encryptSecret(blob) + return { + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: teamName || 'Slack bot', + auditMetadata: { slackTeamId: teamId }, + botUserId, + } +} +/** + * Builds a Google service-account secret from a pasted JSON key. Also the + * fallback for creates without a `providerId` — the original service-account + * flow predates multi-provider support. + */ +async function buildGoogleServiceAccountSecret( + fields: ServiceAccountSecretFields +): Promise { const { serviceAccountJson } = fields if (!serviceAccountJson) { throw new ServiceAccountSecretError( @@ -176,9 +150,89 @@ export async function verifyAndBuildServiceAccountSecret( } const { encrypted } = await encryptSecret(serviceAccountJson) return { - providerId: 'google-service-account', + providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, encryptedServiceAccountKey: encrypted, displayName: jsonParseResult.data.client_email, auditMetadata: {}, } } + +/** + * Builds a token-paste service-account secret for any provider registered in + * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the + * provider's registered validator and persists it (plus any normalized domain + * and non-secret metadata) in the encrypted blob. + */ +async function buildTokenServiceAccountSecret( + providerId: string, + fields: ServiceAccountSecretFields +): Promise { + const descriptor = getTokenServiceAccountDescriptor(providerId) + const validator = getTokenServiceAccountValidator(providerId) + if (!descriptor || !validator) { + throw new ServiceAccountSecretError( + `No validator registered for service-account provider ${providerId}` + ) + } + const apiToken = fields.apiToken?.trim() + const domain = fields.domain?.trim() + const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') + if (!apiToken || (requiresDomain && !domain)) { + const required = descriptor.fields.map((field) => field.id).join(' and ') + throw new ServiceAccountSecretError( + `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` + ) + } + const validation = await validator({ apiToken, domain }) + const blob: TokenServiceAccountSecretBlob = { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId, + apiToken, + ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), + ...(validation.storedMetadata ? { metadata: validation.storedMetadata } : {}), + } + const { encrypted } = await encryptSecret(JSON.stringify(blob)) + return { + providerId, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: validation.auditMetadata, + } +} + +type ServiceAccountSecretBuilder = ( + fields: ServiceAccountSecretFields +) => Promise + +/** + * Builder registry for the bespoke service-account providers. Token-paste + * providers resolve through `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` instead of + * individual entries here. + */ +const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = { + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: buildAtlassianServiceAccountSecret, + [SLACK_CUSTOM_BOT_PROVIDER_ID]: buildSlackCustomBotSecret, + [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: buildGoogleServiceAccountSecret, +} + +/** + * Verifies a service-account secret against its provider, derives the display + * name, and returns the encrypted blob ready to persist. Shared by credential + * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt + * identically. Dispatches through the builder registry (bespoke providers) + * or the token-paste registry; an unknown/missing provider falls back to the + * Google JSON-key builder for legacy creates. Throws + * {@link ServiceAccountSecretError} on missing fields or a failed provider + * verification (callers map it to a 400). + */ +export async function verifyAndBuildServiceAccountSecret( + providerId: string, + fields: ServiceAccountSecretFields +): Promise { + const builder = SERVICE_ACCOUNT_SECRET_BUILDERS[providerId] + if (builder) return builder(fields) + if (isTokenServiceAccountProviderId(providerId)) { + return buildTokenServiceAccountSecret(providerId, fields) + } + return buildGoogleServiceAccountSecret(fields) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index a78672a807c..0d06c8ce185 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -216,6 +216,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< }, ], docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', + helpText: + 'Shopify reveals the Admin API token only once at app creation, and new custom apps are created from the Dev Dashboard or CLI. The token is store-bound and does not expire.', }, [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -231,7 +233,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account', helpText: - 'Site tokens expire after 365 days without API activity, and each token grants access to a single site.', + '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, diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 85059a3f459..65ad9dc0532 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -23,6 +23,43 @@ export class TokenServiceAccountValidationError extends Error { 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. + */ +export async function fetchProvider( + url: string, + init: RequestInit, + step: string +): Promise { + try { + return await fetch(url, init) + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step, + reason: 'network error reaching provider', + }) + } +} + +/** + * Parses a provider response body as JSON, mapping malformed bodies (proxy + * error pages, truncated responses) to `provider_unavailable` instead of an + * unhandled SyntaxError that would surface as a generic 500. + */ +export async function parseProviderJson(res: Response, step: string): Promise { + try { + return (await res.json()) as T + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step, + reason: 'provider returned a non-JSON response body', + }) + } +} + /** * Reads a bounded snippet of a provider error body for server logs. Never * throws — an unreadable body logs as an empty string. diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts index 9937f1d9d11..3c6d9eaf3af 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts @@ -64,6 +64,16 @@ describe('validateAirtableServiceAccount', () => { 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 })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index e5c9c989ce5..ef696b6c166 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -21,19 +23,23 @@ import type { export async function validateAirtableServiceAccount( fields: TokenServiceAccountFields ): Promise { - const whoamiRes = await fetch('https://api.airtable.com/v0/meta/whoami', { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - Accept: 'application/json', + 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 whoamiRes.json()) as { + const whoami = await parseProviderJson<{ id?: string email?: string scopes?: string[] - } + }>(whoamiRes, 'whoami') if (!whoami.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'whoami', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts index e0645cde2f1..f943bd794b6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts @@ -84,6 +84,16 @@ describe('validateAsanaServiceAccount', () => { expect(error.status).toBe(500) }) + it('maps a non-JSON 200 response to provider_unavailable', async () => { + mockFetch.mockResolvedValue(new Response('gateway', { status: 200 })) + + const error = await validateAsanaServiceAccount({ apiToken: 'token-5' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) + it('maps a 200 response missing data.gid to provider_unavailable', async () => { mockFetch.mockResolvedValue(jsonResponse(200, { data: { name: 'No Gid' } })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index d37df7cfc7d..f3e53ac2e66 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -26,15 +28,19 @@ interface AsanaMeResponse { export async function validateAsanaServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch(ASANA_ME_URL, { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - Accept: 'application/json', + 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 res.json()) as AsanaMeResponse + const body = await parseProviderJson(res, 'users_me') const gid = body.data?.gid if (!gid) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index 81eba1dc67a..d9e971c2220 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -91,6 +91,16 @@ describe('validateAttioServiceAccount', () => { ) }) + 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 })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index 72e451a43f0..eea2f55c156 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -25,21 +27,23 @@ interface AttioSelfResponse { export async function validateAttioServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch(ATTIO_SELF_URL, { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - Accept: 'application/json', + const res = await fetchProvider( + ATTIO_SELF_URL, + { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, }, - }) + 'self' + ) await throwForProviderResponse(res, 'self') - let self: AttioSelfResponse - try { - self = (await res.json()) as AttioSelfResponse - } catch { + const self = await parseProviderJson(res, 'self') + if (typeof self !== 'object' || self === null) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'self', - reason: 'non-JSON response body', + reason: 'non-object response body', }) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts index 7842e4485eb..74e116956be 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts @@ -67,6 +67,21 @@ describe('validateCalcomServiceAccount', () => { expect(error.status).toBe(500) }) + it('maps a 200 response with a non-JSON body to provider_unavailable', async () => { + mockFetch.mockResolvedValue( + new Response('gateway timeout', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) + it('maps a 200 response with a non-success envelope to provider_unavailable', async () => { mockFetch.mockResolvedValue(jsonResponse(200, { status: 'error', data: { id: 42 } })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index 6d5ce21ee46..b3a5f1d9a6b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -29,15 +31,19 @@ interface CalcomMeResponse { export async function validateCalcomServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch(CALCOM_ME_URL, { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - Accept: 'application/json', + const res = await fetchProvider( + CALCOM_ME_URL, + { + headers: { + Authorization: `Bearer ${fields.apiToken}`, + Accept: 'application/json', + }, }, - }) + 'me' + ) await throwForProviderResponse(res, 'me') - const body = (await res.json()) as CalcomMeResponse + const body = await parseProviderJson(res, 'me') if (body.status !== 'success' || body.data?.id === undefined) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'me', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 13a4d99d9b5..aef6357800c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -5,7 +5,6 @@ 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' } @@ -41,9 +40,6 @@ describe('validateHubspotServiceAccount', () => { scopes: ['tickets'], }) } - if (url === ACCOUNT_INFO_URL) { - return jsonResponse(403, { status: 'error', category: 'MISSING_SCOPES' }) - } throw new Error(`unexpected fetch: ${url}`) }) @@ -61,17 +57,6 @@ describe('validateHubspotServiceAccount', () => { expect(JSON.parse(init.body)).toEqual({ tokenKey: 'pat-na1-aaaa-bbbb' }) }) - it('upgrades displayName to uiDomain when account-info succeeds', async () => { - mockFetch.mockImplementation(async (url: string) => { - if (url === TOKEN_INFO_URL) return jsonResponse(200, { hubId: 12345 }) - if (url === ACCOUNT_INFO_URL) return jsonResponse(200, { uiDomain: 'app.hubspot.com' }) - throw new Error(`unexpected fetch: ${url}`) - }) - - const result = await validateHubspotServiceAccount(FIELDS) - expect(result.displayName).toBe('app.hubspot.com') - }) - it('throws invalid_credentials on 401', async () => { mockFetch.mockResolvedValue( jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index a967da0ce06..8d2b0b9dc73 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,6 +1,8 @@ import { - TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, throwForProviderResponse, + TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -8,7 +10,6 @@ import type { } 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 @@ -17,59 +18,37 @@ interface HubspotTokenInfo { scopes?: string[] } -/** - * Best-effort display-name upgrade via the account-info endpoint. Its - * documented required scope (`oauth`) cannot be granted to private apps, so a - * 403 (or any other failure) is expected and must never surface as a bad - * token — callers fall back to the hubId-based name silently. - */ -async function fetchPortalUiDomain(accessToken: string): Promise { - try { - const res = await fetch(ACCOUNT_INFO_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - if (!res.ok) return undefined - const details = (await res.json()) as { uiDomain?: string } - return typeof details.uiDomain === 'string' && details.uiDomain ? details.uiDomain : undefined - } catch { - return undefined - } -} - /** * Validates a HubSpot private app access token by calling the access-token-info * endpoint. Both the JSON body (`tokenKey`) and the `Authorization: Bearer` * header are sent — the header is optional for NA (`pat-na1`) tokens but * required for EU (`pat-eu1`) tokens, so one code path covers both regions. * The endpoint requires no scopes, so it validates any private-app token. + * + * The display name is always `HubSpot portal {hubId}` — the account-info + * endpoint's `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), + * not a portal-specific name, so it is not used. */ export async function validateHubspotServiceAccount( fields: TokenServiceAccountFields ): Promise { const accessToken = fields.apiToken - const res = await fetch(TOKEN_INFO_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, + const res = await fetchProvider( + TOKEN_INFO_URL, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ tokenKey: accessToken }), }, - body: JSON.stringify({ tokenKey: accessToken }), - }) + 'access_token_info' + ) await throwForProviderResponse(res, 'access_token_info') - let tokenInfo: HubspotTokenInfo - try { - tokenInfo = (await res.json()) as HubspotTokenInfo - } catch { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'access_token_info', - reason: 'response body is not valid JSON', - }) - } + const tokenInfo = await parseProviderJson(res, 'access_token_info') if (typeof tokenInfo?.hubId !== 'number') { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'access_token_info', @@ -78,14 +57,13 @@ export async function validateHubspotServiceAccount( } const hubId = String(tokenInfo.hubId) - const uiDomain = await fetchPortalUiDomain(accessToken) const storedMetadata: Record = { hubId } if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId) if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId) return { - displayName: uiDomain ?? `HubSpot portal ${hubId}`, + displayName: `HubSpot portal ${hubId}`, auditMetadata: { hubspotHubId: hubId }, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index c8eba25b33b..59e17044369 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -4,6 +4,7 @@ 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() @@ -46,6 +47,7 @@ describe('validateLinearServiceAccount', () => { expect(url).toBe('https://api.linear.app/graphql') expect(init.method).toBe('POST') const headers = init.headers as Record + expect(headers.Authorization).toBe(linearAuthorizationHeader('lin_api_abc')) expect(headers.Authorization).toBe('lin_api_abc') expect(headers.Authorization).not.toContain('Bearer ') expect(JSON.parse(init.body as string)).toEqual({ @@ -81,6 +83,89 @@ describe('validateLinearServiceAccount', () => { }) }) + it('throws provider_unavailable on 400 with a rate-limit body', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + errors: [{ message: 'Rate limit exceeded', extensions: { code: 'RATELIMITED' } }], + }) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 400, + }) + }) + + it('throws invalid_credentials on 400 with an authentication body', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + errors: [ + { message: 'Authentication required', extensions: { code: 'authentication_error' } }, + ], + }) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_bad' })).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 400, + }) + }) + + it('throws provider_unavailable on an ambiguous 400 body', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { errors: [{ message: 'Malformed request' }] }) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 400, + }) + }) + + it('throws invalid_credentials on 200 with a forbidden error', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + errors: [{ message: 'You do not have access', extensions: { type: 'Forbidden' } }], + }) + ) + + await expect( + validateLinearServiceAccount({ apiToken: 'lin_api_scoped' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 200, + }) + }) + + it('throws provider_unavailable on a non-JSON 200 body', async () => { + mockFetch.mockResolvedValueOnce( + new Response('proxy error', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when fetch rejects with a network error', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) + it('throws provider_unavailable on 500', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: [{ message: 'Internal error' }] })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index faa6a1232af..1ff051b986d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,11 +1,14 @@ import { TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, readProviderErrorSnippet, } 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' @@ -39,54 +42,79 @@ interface LinearViewerResponse { * 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 hasAuthenticationError(errors: LinearGraphQLError[] | undefined): boolean { +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/i.test(value)) + 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. Personal keys are sent as a bare - * `Authorization: ` header — no `Bearer` prefix — per Linear's official - * docs (the Bearer form is reserved for OAuth access tokens). GraphQL can + * 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. + * the body are inspected. Linear also returns HTTP 400 for rate/complexity + * limits (`RATELIMITED`), so a 400 only maps to `invalid_credentials` when + * the body carries authentication evidence. */ export async function validateLinearServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch(LINEAR_GRAPHQL_URL, { - method: 'POST', - headers: { - Authorization: fields.apiToken, - 'Content-Type': 'application/json', + const res = await fetchProvider( + LINEAR_GRAPHQL_URL, + { + method: 'POST', + headers: { + Authorization: linearAuthorizationHeader(fields.apiToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: VIEWER_QUERY }), }, - body: JSON.stringify({ query: VIEWER_QUERY }), - }) + 'viewer' + ) if (!res.ok) { const body = await readProviderErrorSnippet(res) - if (res.status === 400 || res.status === 401 || res.status === 403) { + 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 res.json()) as LinearViewerResponse - if (hasAuthenticationError(payload.errors)) { + const payload = await parseProviderJson(res, 'viewer') + if (hasCredentialError(payload.errors)) { throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { step: 'viewer', - reason: 'GraphQL authentication error in 200 response', + reason: 'GraphQL authentication/authorization error in 200 response', }) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index ca891d63e50..2e804095549 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -86,6 +86,25 @@ describe('validateMondayServiceAccount', () => { 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 on 500', async () => { mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 9380a657f03..6030a229a5a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -15,10 +17,28 @@ interface MondayValidationBody { me?: { id?: string | number; name?: string; email?: string } account?: { id?: string | number; name?: string; slug?: string } } - errors?: Array<{ message?: string }> + errors?: Array<{ + message?: string + extensions?: { code?: string; status_code?: number } + }> error_message?: string } +const SERVER_SIDE_ERROR_CODE = /internal|server_error|unavailable/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`, per the + * monday API error documentation. + */ +function isProviderSideError(body: MondayValidationBody): boolean { + const extensions = body.errors?.[0]?.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` @@ -48,25 +68,27 @@ function extractBodyError(body: MondayValidationBody): string | null { export async function validateMondayServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch(MONDAY_API_URL, { - method: 'POST', - headers: mondayHeaders(fields.apiToken), - body: JSON.stringify({ query: VALIDATION_QUERY }), - }) + const res = await fetchProvider( + MONDAY_API_URL, + { + method: 'POST', + headers: mondayHeaders(fields.apiToken), + body: JSON.stringify({ query: VALIDATION_QUERY }), + }, + 'me' + ) await throwForProviderResponse(res, 'me') - let body: MondayValidationBody - try { - body = (await res.json()) as MondayValidationBody - } catch { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'me', - reason: 'non-JSON response body', - }) - } + const body = await parseProviderJson(res, 'me') const bodyError = 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, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts index f4c53df4729..47e6823bb94 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -84,6 +84,16 @@ describe('validateNotionServiceAccount', () => { }) }) + it('throws provider_unavailable when a 200 body is not JSON', async () => { + mockFetch.mockResolvedValueOnce(new Response('gateway', { status: 200 })) + + const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) + it('throws provider_unavailable on 502', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(502, { object: 'error' })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index ce101bfb868..a69ace4d6f4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -1,6 +1,8 @@ import { - TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, throwForProviderResponse, + TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -32,16 +34,20 @@ interface NotionBotUser { export async function validateNotionServiceAccount( fields: TokenServiceAccountFields ): Promise { - const res = await fetch('https://api.notion.com/v1/users/me', { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - 'Notion-Version': NOTION_VERSION, - Accept: 'application/json', + 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 res.json()) as NotionBotUser + const me = await parseProviderJson(res, 'users_me') if (!me.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'users_me', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 5514266ef85..bc038737529 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -31,7 +31,6 @@ describe('validateShopifyServiceAccount', () => { shop: { name: 'Acme Store', myshopifyDomain: 'acme-store.myshopify.com', - email: 'ops@acme.com', }, }, }) @@ -56,7 +55,7 @@ describe('validateShopifyServiceAccount', () => { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': 'shpat_abc', }, - body: JSON.stringify({ query: '{ shop { name myshopifyDomain email } }' }), + body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }), } ) }) @@ -111,4 +110,35 @@ describe('validateShopifyServiceAccount', () => { expect(error.code).toBe('provider_unavailable') expect(error.status).toBe(500) }) + + it('throws provider_unavailable when a 200 body carries GraphQL errors', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { errors: [{ message: 'Internal error' }] }) + ) + + await expect( + validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when a 200 body is not JSON', async () => { + mockFetch.mockResolvedValueOnce( + new Response('gateway error', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + await expect( + validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index e6c2dc7a786..63bd1a42182 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -1,5 +1,7 @@ import { TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, readProviderErrorSnippet, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' @@ -9,8 +11,10 @@ import type { } from '@/lib/credentials/token-service-accounts/server' /** - * Pinned to the same Admin API version every Sim Shopify tool uses so - * validation and tool runtime can never diverge. + * 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' @@ -21,14 +25,13 @@ const SHOPIFY_API_VERSION = '2024-10' */ const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ -const SHOP_QUERY = '{ shop { name myshopifyDomain email } }' +const SHOP_QUERY = '{ shop { name myshopifyDomain } }' interface ShopifyShopResponse { data?: { shop?: { name?: string myshopifyDomain?: string - email?: string } } errors?: unknown @@ -61,14 +64,18 @@ export async function validateShopifyServiceAccount( }) } - const res = await fetch(`https://${domain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Shopify-Access-Token': fields.apiToken, + 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 }), }, - body: JSON.stringify({ query: SHOP_QUERY }), - }) + 'shop_query' + ) if (res.status === 404) { throw new TokenServiceAccountValidationError('site_not_found', 404, { @@ -79,16 +86,7 @@ export async function validateShopifyServiceAccount( } await throwForProviderResponse(res, 'shop_query', { domain }) - let payload: ShopifyShopResponse - try { - payload = (await res.json()) as ShopifyShopResponse - } catch { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'shop_query', - domain, - reason: 'response body is not valid JSON', - }) - } + const payload = await parseProviderJson(res, 'shop_query') const shop = payload.data?.shop if (payload.errors || !shop) { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts index f515211e739..5e78de2f1d6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts @@ -59,7 +59,7 @@ describe('validateTrelloServiceAccount', () => { expect(parsed.searchParams.get('fields')).toBe('id,fullName,username') }) - it('throws invalid_credentials on 401', async () => { + it('throws invalid_credentials on 401 with an invalid token body', async () => { mockFetch.mockResolvedValue(jsonResponse(401, 'invalid token')) await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ @@ -69,6 +69,45 @@ describe('validateTrelloServiceAccount', () => { }) }) + it('throws provider_unavailable on 401 with an invalid key body', async () => { + mockFetch.mockResolvedValue(jsonResponse(401, 'invalid key')) + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 401, + logDetail: { step: 'members_me', reason: 'Trello rejected the server API key' }, + }) + }) + + it('throws invalid_credentials on 401 with any other body', async () => { + mockFetch.mockResolvedValue(jsonResponse(401, 'unauthorized')) + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + }) + + it('throws provider_unavailable on a non-JSON 200 body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: '', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON') + }, + text: async () => 'proxy error', + } as unknown as Response) + + await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) + it('throws provider_unavailable on 500', async () => { mockFetch.mockResolvedValue(jsonResponse(500, { message: 'unavailable' })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 30258b94d26..2ac940a8261 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -1,6 +1,9 @@ import { env } from '@/lib/core/config/env' import { TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, + readProviderErrorSnippet, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { @@ -22,7 +25,10 @@ interface TrelloMember { * 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). + * 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 @@ -39,20 +45,33 @@ export async function validateTrelloServiceAccount( url.searchParams.set('token', fields.apiToken) url.searchParams.set('fields', 'id,fullName,username') - const res = await fetch(url.toString(), { - headers: { Accept: 'application/json' }, - }) - await throwForProviderResponse(res, 'members_me') - - let member: TrelloMember - try { - member = (await res.json()) as TrelloMember - } catch { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + 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', - reason: 'response body is not valid JSON', + body, }) } + await throwForProviderResponse(res, 'members_me') + + const member = await parseProviderJson(res, 'members_me') if (typeof member?.id !== 'string' || !member.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'members_me', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts index cb19a7bff9f..50f8739d879 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts @@ -83,6 +83,19 @@ describe('validateWealthboxServiceAccount', () => { }) }) + 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' })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index 93670f7b1c6..787f7d2b6ca 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -1,7 +1,9 @@ import { - TokenServiceAccountValidationError, + fetchProvider, + parseProviderJson, readProviderErrorSnippet, throwForProviderResponse, + TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -28,9 +30,11 @@ interface WealthboxMeResponse { */ async function probeAccessTokenHeader(apiToken: string): Promise { try { - const res = await fetch(ME_URL, { - headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' }, - }) + const res = await fetchProvider( + ME_URL, + { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' } }, + 'me-access-token-probe' + ) return res.ok } catch { return false @@ -44,15 +48,28 @@ async function probeAccessTokenHeader(apiToken: string): Promise { * existing tool code. If Bearer is rejected but the documented `ACCESS_TOKEN` * header succeeds, the token is real yet unusable by Sim's tools, so it is * still rejected as `invalid_credentials` with a distinguishing log detail. + * + * A 402 is documented by Wealthbox as "Wealthbox trial account has expired" + * and maps to `invalid_credentials` — retrying later would never succeed, so + * the user is prompted to check their Wealthbox account instead. */ export async function validateWealthboxServiceAccount( fields: TokenServiceAccountFields ): Promise { const apiToken = fields.apiToken - const res = await fetch(ME_URL, { - headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, - }) + 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) @@ -71,15 +88,7 @@ export async function validateWealthboxServiceAccount( } await throwForProviderResponse(res, 'me') - let me: WealthboxMeResponse - try { - me = (await res.json()) as WealthboxMeResponse - } catch { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'me', - reason: 'response body is not valid JSON', - }) - } + const me = await parseProviderJson(res, 'me') const displayName = me.name || me.email || 'Wealthbox account' const userId = diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts index e4cc65ed881..f95d9cc1253 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -77,6 +77,21 @@ describe('validateWebflowServiceAccount', () => { expect(error.status).toBe(500) }) + it('throws provider_unavailable on a 200 with a non-JSON body', async () => { + mockFetch.mockResolvedValueOnce( + new Response('gateway timeout', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e) + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + }) + it('throws provider_unavailable on 200 with empty sites array', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(200, { sites: [] })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index af2c36f1ff9..cfda87a1910 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -1,4 +1,6 @@ import { + fetchProvider, + parseProviderJson, throwForProviderResponse, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -26,15 +28,19 @@ interface WebflowSite { export async function validateWebflowServiceAccount( fields: TokenServiceAccountFields ): Promise { - const sitesRes = await fetch('https://api.webflow.com/v2/sites', { - headers: { - Authorization: `Bearer ${fields.apiToken}`, - Accept: 'application/json', + 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 sitesRes.json()) as { sites?: WebflowSite[] } + const payload = await parseProviderJson<{ sites?: WebflowSite[] }>(sitesRes, 'list_sites') const site = payload.sites?.[0] if (!site?.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index 04780af9aaf..83fe3268821 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -48,7 +48,7 @@ export const shopifyAdjustInventoryTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index 97c9eb317c6..b86c62b533a 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -64,7 +64,7 @@ export const shopifyCancelOrderTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts index fac5bc17045..ca3e090872e 100644 --- a/apps/sim/tools/shopify/create_customer.ts +++ b/apps/sim/tools/shopify/create_customer.ts @@ -69,7 +69,7 @@ export const shopifyCreateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts index 6537fda0567..211f7f96e43 100644 --- a/apps/sim/tools/shopify/create_fulfillment.ts +++ b/apps/sim/tools/shopify/create_fulfillment.ts @@ -61,7 +61,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index bfee95f7c5f..4a57deba2c4 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -63,7 +63,7 @@ export const shopifyCreateProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts index 06dfb072c4e..53e615e2a40 100644 --- a/apps/sim/tools/shopify/delete_customer.ts +++ b/apps/sim/tools/shopify/delete_customer.ts @@ -32,7 +32,7 @@ export const shopifyDeleteCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts index c00f46ab697..cca4e35fecc 100644 --- a/apps/sim/tools/shopify/delete_product.ts +++ b/apps/sim/tools/shopify/delete_product.ts @@ -32,7 +32,7 @@ export const shopifyDeleteProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index 480ea0ab07b..29e3f859205 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -40,7 +40,7 @@ export const shopifyGetCollectionTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts index ef83449d198..67d0810dfdb 100644 --- a/apps/sim/tools/shopify/get_customer.ts +++ b/apps/sim/tools/shopify/get_customer.ts @@ -31,7 +31,7 @@ export const shopifyGetCustomerTool: ToolConfig - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index b39f9ca56f9..b5e315cdae7 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -42,7 +42,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts index 93b552c6b3d..9093b8ea0c3 100644 --- a/apps/sim/tools/shopify/get_order.ts +++ b/apps/sim/tools/shopify/get_order.ts @@ -30,7 +30,7 @@ export const shopifyGetOrderTool: ToolConfig - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts index afe9be4949f..6129a85421e 100644 --- a/apps/sim/tools/shopify/get_product.ts +++ b/apps/sim/tools/shopify/get_product.ts @@ -30,7 +30,7 @@ export const shopifyGetProductTool: ToolConfig - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index e50ec507753..fe22a3f7856 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -44,7 +44,7 @@ export const shopifyListCollectionsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts index 09d160deeb1..46f3370d9fb 100644 --- a/apps/sim/tools/shopify/list_customers.ts +++ b/apps/sim/tools/shopify/list_customers.ts @@ -40,7 +40,7 @@ export const shopifyListCustomersTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 97c3992a733..997cbcdc46d 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -44,7 +44,7 @@ export const shopifyListInventoryItemsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index 6d6e675f5be..4894f12810f 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -40,7 +40,7 @@ export const shopifyListLocationsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts index 1e78c4a7ae2..eb415e8759e 100644 --- a/apps/sim/tools/shopify/list_orders.ts +++ b/apps/sim/tools/shopify/list_orders.ts @@ -43,7 +43,7 @@ export const shopifyListOrdersTool: ToolConfig - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts index 3903f9df073..2acd27562a4 100644 --- a/apps/sim/tools/shopify/list_products.ts +++ b/apps/sim/tools/shopify/list_products.ts @@ -40,7 +40,7 @@ export const shopifyListProductsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts index fb877199fe3..d010c0b0cb9 100644 --- a/apps/sim/tools/shopify/types.ts +++ b/apps/sim/tools/shopify/types.ts @@ -725,7 +725,10 @@ interface ShopifyInventoryItem { interface ShopifyBaseParams { accessToken: string shopDomain: string - idToken?: string // Shop domain from OAuth, used as fallback + /** Store domain resolved from a service-account credential */ + domain?: string + /** Shop domain from OAuth, used as fallback */ + idToken?: string } // Product Tool Params diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts index e44b2ea7a93..513ccb906d4 100644 --- a/apps/sim/tools/shopify/update_customer.ts +++ b/apps/sim/tools/shopify/update_customer.ts @@ -69,7 +69,7 @@ export const shopifyUpdateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts index 1d161722e1e..58d4415ac32 100644 --- a/apps/sim/tools/shopify/update_order.ts +++ b/apps/sim/tools/shopify/update_order.ts @@ -48,7 +48,7 @@ export const shopifyUpdateOrderTool: ToolConfig - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index caa2adbf930..729074947b0 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -69,7 +69,7 @@ export const shopifyUpdateProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { From 0dd4a023837ea8efc16f89a25d0f2b1c9c5553aa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:02:29 -0700 Subject: [PATCH 03/10] polish(credentials): ship-gate fixes, setup-guide docs, and lint pass for token service accounts - 12 setup-guide docs pages (docs.sim.ai/integrations/-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 --- .../integrations/airtable-service-account.mdx | 108 ++++++++++++++ .../en/integrations/asana-service-account.mdx | 114 ++++++++++++++ .../en/integrations/attio-service-account.mdx | 102 +++++++++++++ .../integrations/calcom-service-account.mdx | 82 ++++++++++ .../integrations/hubspot-service-account.mdx | 141 ++++++++++++++++++ .../integrations/linear-service-account.mdx | 86 +++++++++++ .../content/docs/en/integrations/meta.json | 12 ++ .../integrations/monday-service-account.mdx | 98 ++++++++++++ .../integrations/notion-service-account.mdx | 114 ++++++++++++++ .../integrations/shopify-service-account.mdx | 113 ++++++++++++++ .../integrations/trello-service-account.mdx | 95 ++++++++++++ .../wealthbox-service-account.mdx | 80 ++++++++++ .../integrations/webflow-service-account.mdx | 89 +++++++++++ apps/sim/app/api/auth/oauth/utils.ts | 23 +-- apps/sim/app/api/credentials/route.ts | 2 +- .../connect-service-account-modal.tsx | 2 +- .../lib/credentials/orchestration/index.ts | 5 +- .../token-service-accounts/descriptors.ts | 2 +- .../token-service-accounts/errors.test.ts | 137 +++++++++++++++++ .../token-service-accounts/server.ts | 11 +- .../validators/airtable.test.ts | 6 +- .../validators/airtable.ts | 2 +- .../validators/asana.ts | 2 +- .../validators/attio.test.ts | 12 ++ .../validators/attio.ts | 13 +- .../validators/calcom.ts | 2 +- .../validators/hubspot.ts | 2 +- .../validators/linear.test.ts | 8 +- .../validators/linear.ts | 2 +- .../validators/monday.test.ts | 34 +++++ .../validators/monday.ts | 27 ++-- .../validators/notion.test.ts | 4 +- .../validators/notion.ts | 2 +- .../validators/shopify.test.ts | 4 +- .../validators/shopify.ts | 2 +- .../validators/trello.ts | 2 +- .../validators/wealthbox.ts | 5 +- .../validators/webflow.ts | 2 +- apps/sim/lib/integrations/oauth-service.ts | 2 +- 39 files changed, 1498 insertions(+), 51 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/airtable-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/asana-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/attio-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/calcom-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/hubspot-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/linear-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/monday-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/notion-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/shopify-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/trello-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx create mode 100644 apps/docs/content/docs/en/integrations/webflow-service-account.mdx create mode 100644 apps/sim/lib/credentials/token-service-accounts/errors.test.ts diff --git a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx new file mode 100644 index 00000000000..18b95f1eff6 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx @@ -0,0 +1,108 @@ +--- +title: Airtable Service Accounts +description: Set up an Airtable personal access token with scoped base access to use Airtable in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Airtable personal access tokens (PATs) let your workflows authenticate to Airtable with a token that has explicit scopes and explicit base access — instead of a person's OAuth login. The token never expires on its own, and what it can touch is decided at creation time. + +On Airtable Enterprise Scale plans, admins can create the same tokens from a dedicated **service account** in the Admin Panel. Those tokens are wire-identical to personal access tokens and are the better choice for organization-level use: they don't stop working when an employee leaves. + +## Prerequisites + +Any Airtable user can create a personal access token for the bases they have access to. Keep in mind the token is capped by its creator's own permissions — a PAT created by a read-only collaborator cannot write, no matter which scopes it has. Create the token from an account with the access level your workflows need. + +For service-account tokens, you need an Enterprise Scale plan and an admin with access to the Admin Panel. + +## Creating the Token + + + + Open [airtable.com/create/tokens](https://airtable.com/create/tokens) and click **Create token** + + {/* TODO(screenshot): Airtable token builder page with the "Create token" button */} + + + Give the token a name (e.g. `sim-airtable-bot`) + + + Add the scopes your workflows need. The full set Sim's Airtable tools and triggers use is: + + ``` + data.records:read + data.records:write + schema.bases:read + user.email:read + webhook:manage + ``` + + Reading records needs `data.records:read`; creating, updating, upserting, and deleting records need `data.records:write`; listing bases and tables and reading base schemas need `schema.bases:read`; `user.email:read` lets Sim show the token owner's email as the credential name; and `webhook:manage` is only needed if you use Airtable triggers in Sim. + + {/* TODO(screenshot): token builder scopes section with the five scopes added */} + + + Under **Access**, add the bases or entire workspaces the token should reach. The token can only see what you list here — a token with all five scopes but no base access can't read anything. + + {/* TODO(screenshot): token builder access section with a workspace added */} + + + Click **Create token** and copy it when it's shown. Airtable only displays it once — if you close the dialog, you'll have to regenerate it. + + + + +The token is bearer credentials for every base you granted. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +### Enterprise Scale: Service Account Tokens + +On Enterprise Scale plans, an admin can create up to 30 service accounts (raisable via Airtable support) from the Admin Panel, each with its own name and email. From a service account's **Personal access tokens** panel, the admin creates tokens with the same scopes-plus-base-access builder shown above. The resulting token works in Sim exactly like a user PAT — same format, same `pat…` prefix, same behavior — but it belongs to the organization, not a person, so it survives employee offboarding. If your plan supports it, prefer this over a user-created PAT. + + +Enterprise organizations can enable "Block API access to organization-owned bases and workspaces". If your organization uses this setting, the service account must be allowlisted or every call fails despite a valid token. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Airtable Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Airtable Service Account" in the service list */} + + + Paste the **Personal access token**, and optionally set a display name and description + + {/* TODO(screenshot): Add Airtable Service Account dialog with the personal access token filled in */} + + + Click **Add Service Account**. Sim verifies the token by calling Airtable's `whoami` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + + +Validation only proves the token is real — Airtable doesn't expose a PAT's scopes or base access for inspection. A token that validates fine can still fail every tool call if you skipped a scope or forgot to grant base access. If a block returns a 403 (`INVALID_PERMISSIONS`) or an unexpected 404 (`NOT_FOUND`), check the token's scopes and base access first. + + +## Using the Service Account in Workflows + +Add an Airtable block to your workflow. In the credential dropdown, your Airtable service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Airtable block in a workflow with the Airtable service account selected as the credential */} + +The block calls `api.airtable.com` using the token — exactly the same requests as the OAuth flow, so every Airtable tool works unchanged, limited to the scopes and bases you granted. + + diff --git a/apps/docs/content/docs/en/integrations/asana-service-account.mdx b/apps/docs/content/docs/en/integrations/asana-service-account.mdx new file mode 100644 index 00000000000..ce3a3bfd33a --- /dev/null +++ b/apps/docs/content/docs/en/integrations/asana-service-account.mdx @@ -0,0 +1,114 @@ +--- +title: Asana Service Accounts +description: Connect Asana to Sim with a service account token or a personal access token from a dedicated bot account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Asana service accounts let your workflows authenticate as a non-human bot identity instead of a person's account. The service account has its own name and its own token, managed centrally by a super admin — no one person's OAuth consent to expire, and access can be revoked without touching anyone's personal account. + +There are two ways to get a token, and both work in Sim: + +- **Service account token** (recommended for teams) — available on Asana **Enterprise** and **Enterprise+** plans, created by a super admin in the Admin Console. +- **Personal access token (PAT)** — available on any plan, created from any user account. For production workflows, create it from a dedicated bot account rather than a personal one. + +Both token types use the same format and paste into the same field in Sim. + +## Option A: Service Account Token (Enterprise / Enterprise+) + +### Prerequisites + +You need an Asana **super admin** on an **Enterprise** or **Enterprise+** plan. Service accounts are an organization-level feature — regular users cannot create them. + +### Create the Service Account + + + + Open the Asana **Admin Console** and go to **Apps** → **Service accounts** + + {/* TODO(screenshot): Asana Admin Console with Apps → Service accounts highlighted */} + + + Click **Add service account**, give it a name (e.g. `Sim Integration`) — this is the name that will appear on tasks and comments the workflows create + + + Grant the service account **Full Permissions**. Scoped service accounts (for example, ones limited to User Provisioning / SCIM) cannot call the standard Asana API and will fail when you connect them to Sim + + {/* TODO(screenshot): service account permission picker with "Full Permissions" selected */} + + + Copy the token when it's shown. Asana only displays it once — if you lose it, you'll have to regenerate it, which immediately invalidates the old token. + + + + +A service account with Full Permissions has org-wide access to all data in your Asana organization, including private projects. Treat the token like an admin password. + + +## Option B: Personal Access Token + +If you're not on an Enterprise plan, a personal access token works identically on the wire. For production workflows, create a dedicated bot user account (e.g. `sim-bot@yourcompany.com`) and generate the PAT from that account — a PAT is tied to the user who created it, inherits only that user's permissions, and stops working if the user is deprovisioned. + + + + Log in as the bot account and open the developer console at [app.asana.com/0/my-apps](https://app.asana.com/0/my-apps) + + {/* TODO(screenshot): Asana developer console "My apps" page with "Create new token" button */} + + + Click **Create new token**, give it a description, and agree to the API terms + + + Copy the token immediately — Asana only shows it once. + + + Add the bot account to the workspaces and projects your workflows need. A PAT can only see what its user can see. + + + +## Adding the Token to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Asana Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Asana Service Account" in the service list */} + + + Paste the token — service account token or personal access token, both work in the same field — and optionally set a display name and description + + {/* TODO(screenshot): Add Asana Service Account dialog with the access token filled in */} + + + Click **Add Service Account**. Sim verifies the token by calling Asana's `/users/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + + +If a brand-new token fails validation with an authentication error, wait a few minutes and try again — newly created service account tokens can take a short time to become active. + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add an Asana block to your workflow. In the credential dropdown, your Asana service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Asana block in a workflow with the service account selected as the credential */} + +The block calls the Asana API (`app.asana.com/api/1.0`) with the token as a standard Bearer credential. There's no impersonation step — the token acts as itself: the service account identity, or the bot user for a PAT. + + diff --git a/apps/docs/content/docs/en/integrations/attio-service-account.mdx b/apps/docs/content/docs/en/integrations/attio-service-account.mdx new file mode 100644 index 00000000000..9480a7cc138 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/attio-service-account.mdx @@ -0,0 +1,102 @@ +--- +title: Attio Service Accounts +description: Connect Attio to Sim with a workspace API key — a workspace-level, non-expiring access token with admin-chosen scopes +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Attio workspace API keys let your workflows authenticate at the workspace level instead of through a person's account. The key is created by a workspace admin, belongs to the workspace rather than any individual, and does not expire — no one person's OAuth consent to lapse, and access can be revoked at any time by deleting the key. + +## Prerequisites + +You need an Attio **workspace admin**. Only admins can create and manage access tokens. + +## Creating the API Key + + + + Open **Workspace settings** in Attio and go to **Developers** → **API keys** + + {/* TODO(screenshot): Attio workspace settings with Developers → API keys highlighted */} + + + Click **New access token** and give it a name (e.g. `Sim Integration`) + + + Select the scopes the key needs. To match everything Sim's Attio blocks can do, grant: + + ``` + record_permission (read-write) + object_configuration (read-write) + list_configuration (read-write) + list_entry (read-write) + note (read-write) + task (read-write) + comment (read-write) + user_management (read) + webhook (read-write) + ``` + + Record and object tools need `record_permission` plus `object_configuration`; list tools need `list_configuration` and `list_entry`; note, task, comment, and webhook tools need their respective scopes; member lookups need `user_management:read`. + + {/* TODO(screenshot): Attio access token scope picker with the scopes above selected */} + + + Copy the key and store it somewhere safe. + + + + +Scopes are editable after creation — if a workflow later fails with a permission error, an admin can add the missing scope to the existing key in the Developers settings without rotating it. + + + +The API key is bearer credentials for your Attio workspace. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the key at rest. + + +## Adding the API Key to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Attio Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Attio Service Account" in the service list */} + + + Paste the API key and optionally set a display name and description + + {/* TODO(screenshot): Add Attio Service Account dialog with the API key filled in */} + + + Click **Add Service Account**. Sim verifies the key by calling Attio's `/v2/self` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The key is encrypted before being stored. + + +A key with missing scopes still validates successfully — the validation endpoint is reachable with any live key. Check the scopes granted to the key in Attio: tools whose scopes are missing will fail at run time with permission errors. + + +## Using the Service Account in Workflows + +Add an Attio block to your workflow. In the credential dropdown, your Attio service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Attio block in a workflow with the service account selected as the credential */} + +The block calls the Attio API (`api.attio.com/v2`) with the key as a standard Bearer credential, with whatever scopes the admin granted the key. + + diff --git a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx new file mode 100644 index 00000000000..c786e5a2f82 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx @@ -0,0 +1,82 @@ +--- +title: Cal.com Service Accounts +description: Set up a Cal.com API key to use Cal.com in Sim workflows without OAuth +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Cal.com service account credentials let your workflows authenticate to Cal.com with a pasted API key instead of a personal OAuth connection. The key carries the full permissions of the Cal.com user who created it, and it stays valid until it expires or is revoked — no OAuth consent to renew. + +## Prerequisites + +You need a Cal.com account (cal.com cloud). Any user can create API keys from their own settings — no admin role required. + + +Cal.com API keys are tied to the user who creates them. For production workflows, create the key from a dedicated service login (e.g. `sim-bot@yourcompany.com`) rather than a personal account — the credential then survives any individual employee leaving, and bookings and event types the workflows manage belong to the bot user. + + +## Creating the API Key + + + + Log in to Cal.com and go to **Settings** → **Developer** → **API keys** (direct link: [app.cal.com/settings/developer/api-keys](https://app.cal.com/settings/developer/api-keys)) + + {/* TODO(screenshot): Cal.com settings Developer section with the API keys page open */} + + + Click **+ Add**, give the key a name (e.g. `sim-workflows`), and set its expiry. Choose a non-expiring key if the option is available — otherwise pick the longest expiry offered and note the date so you can rotate before it lapses + + {/* TODO(screenshot): Cal.com create API key dialog with name and expiry fields */} + + + Copy the key when it's shown. Cal.com only displays it once — if you close the dialog, you'll have to create a new key. + + + +Live-mode keys start with `cal_live_`; test-mode keys start with `cal_`. Either form is accepted. + + +The API key carries the full privileges of the user who created it — there is no scope selection. Treat it like a password: do not commit it to source control or share it publicly. Sim encrypts the key at rest. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Cal.com Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Cal.com Service Account" in the service list */} + + + Paste the API key, and optionally set a display name and description + + {/* TODO(screenshot): Add Cal.com Service Account dialog with the API key filled in */} + + + Click **Add Service Account**. Sim verifies the key by calling Cal.com's `/v2/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The key is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a Cal.com block to your workflow. In the credential dropdown, your Cal.com service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Cal.com block in a workflow with the Cal.com service account selected as the credential */} + +The block calls Cal.com's API (`api.cal.com/v2`) with the key. The credential acts as the Cal.com user who created the key — bookings, event types, and schedules the workflows touch are the ones that user can see and manage. + + diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx new file mode 100644 index 00000000000..1e9e53022a7 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -0,0 +1,141 @@ +--- +title: HubSpot Service Accounts +description: Set up a HubSpot private app with a scoped access token to use HubSpot in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +HubSpot private apps let your workflows authenticate to HubSpot with a portal-level access token instead of a person's OAuth login. A super admin creates the app once, picks exactly the scopes it needs, and the token stays valid until it's rotated or revoked — no OAuth consent to expire, and permissions that are auditable from HubSpot's settings. + +This is the recommended way to use HubSpot in production workflows: the token doesn't auto-expire, its scopes are explicit, and it can be rotated on a schedule without touching anyone's personal account. + +## Prerequisites + +You need a HubSpot **super admin** to create the private app. Private apps can only be created and managed by super admins in the portal. + + +The token is tied to the super admin who created the private app. If that user is later removed from the portal (or loses super admin), some API calls — notably associations — start failing with `USER_DOES_NOT_HAVE_PERMISSIONS`. Create the app from an account you expect to keep, and rotate the token if the creator ever leaves. + + +## Setting Up the Private App + +### 1. Create the Private App + + + + In HubSpot, open **Settings** → **Integrations** → **Legacy apps** → **Private apps** + + {/* TODO(screenshot): HubSpot settings navigation showing Integrations → Legacy apps → Private apps */} + + + HubSpot recently relabeled private apps as "Legacy apps" in its settings navigation. The tokens themselves remain fully supported — only the menu label changed. If you don't see "Legacy apps", look for "Private Apps" directly under Integrations. + + + + Click **Create a private app** and give it a name (e.g. `sim-hubspot-bot`) and description + + + Open the **Scopes** tab and select the scopes your workflows need (see the list below) + + {/* TODO(screenshot): private app Scopes tab with CRM scopes selected */} + + + Click **Create app**, confirm, and copy the access token from the app's **Auth** tab + + {/* TODO(screenshot): private app Auth tab showing the access token with the Show/Copy controls */} + + + +### 2. Choose Scopes + +Private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is: + +**CRM objects (read/write as needed):** +``` +crm.objects.contacts.read +crm.objects.contacts.write +crm.objects.companies.read +crm.objects.companies.write +crm.objects.deals.read +crm.objects.deals.write +crm.objects.line_items.read +crm.objects.line_items.write +crm.objects.appointments.read +crm.objects.appointments.write +crm.objects.owners.read +crm.objects.users.read +crm.objects.marketing_events.read +crm.objects.quotes.read +crm.objects.carts.read +``` + +**Tickets, emails, and lists:** +``` +tickets +sales-email-read +crm.lists.read +crm.lists.write +``` + +Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab and the change takes effect immediately, no new token needed. + + +Sim's OAuth flow also requests an `oauth` scope — that one is OAuth-app-only and doesn't exist for private apps. You don't need it and can't grant it. + + +### 3. Copy the Token + +The token looks like `pat-na1-…` (North America) or `pat-eu1-…` (EU data residency). Both work — Sim calls `api.hubapi.com`, which serves both regions. + + +The access token is bearer credentials for your entire portal, limited only by its scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "HubSpot Service Account" and click it + + {/* TODO(screenshot): Integrations page with "HubSpot Service Account" in the service list */} + + + Paste the **Private app access token**, and optionally set a display name and description + + {/* TODO(screenshot): Add HubSpot Service Account dialog with the private app access token filled in */} + + + Click **Add Service Account**. Sim verifies the token against HubSpot's token-info endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +## Using the Service Account in Workflows + +Add a HubSpot block to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): HubSpot block in a workflow with the HubSpot service account selected as the credential */} + +The block calls `api.hubapi.com` using the private app token — exactly the same requests as the OAuth flow, so every HubSpot tool works unchanged, subject to the scopes you granted. + +## Rotating the Token + +Private app tokens don't auto-expire, but HubSpot recommends rotating them every six months and emails super admins once a token hasn't rotated in about 180 days. From the app's Auth tab you can: + +- **Rotate and expire now** — the old token dies immediately +- **Rotate and expire later** — the old token keeps working for 7 days while you migrate + +Prefer the 7-day option: rotate in HubSpot, paste the new token into the credential in Sim, confirm your workflows run, and let the old token lapse. + + diff --git a/apps/docs/content/docs/en/integrations/linear-service-account.mdx b/apps/docs/content/docs/en/integrations/linear-service-account.mdx new file mode 100644 index 00000000000..da5d943cc48 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/linear-service-account.mdx @@ -0,0 +1,86 @@ +--- +title: Linear Service Accounts +description: Connect Linear to Sim with a personal API key — ideally created from a dedicated service user for production workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Linear personal API keys let your workflows authenticate without an OAuth consent flow. A key gives full parity with the Linear API — everything Sim's Linear blocks can do over OAuth works with a key. + +Keys are bound to the user who creates them: every action a workflow takes is attributed to that user, and the key stops working if the user is deactivated or leaves the workspace. For production workflows, create the key from a dedicated service user (e.g. `sim-bot@yourcompany.com`) rather than a personal account. + +## Prerequisites + +A Linear account that is allowed to create API keys. Workspace admins can disable API-key creation for members (**Settings** → **Administration** → **API** → **Member API keys**) — if the option is missing for you, ask an admin, or have an admin create the key (the restriction doesn't apply to admins). + +## Creating the API Key + + + + Log in as the service user, open Linear **Settings**, and go to **Security & access** → **API keys** + + {/* TODO(screenshot): Linear settings with Security & access → API keys highlighted */} + + + Click **New API key** and give it a label (e.g. `Sim Integration`) + + + Choose the key's permissions. For full parity with Sim's Linear blocks, create it with **full access** — or, if you restrict it, grant at least **Read** and **Write** with no team restriction. A key limited to specific teams will fail on issues and projects outside those teams + + {/* TODO(screenshot): Linear API key creation dialog with permission options visible */} + + + Copy the key — it starts with `lin_api_` — and store it somewhere safe. + + + + +The API key carries the creating user's full access to your Linear workspace. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the key at rest. + + +## Adding the API Key to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Linear Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Linear Service Account" in the service list */} + + + Paste the API key (`lin_api_...`) and optionally set a display name and description + + {/* TODO(screenshot): Add Linear Service Account dialog with the API key filled in */} + + + Click **Add Service Account**. Sim verifies the key by querying Linear's `viewer` — if it fails, you'll see a specific error explaining what went wrong. + + + + +A key created with permission restrictions that block reading the viewer (the key's own user) is rejected at connect time. Create the key with full access, or at minimum Read + Write, so it validates and every block works. + + +The key is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a Linear block to your workflow. In the credential dropdown, your Linear service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Linear block in a workflow with the service account selected as the credential */} + +The block calls Linear's GraphQL API (`api.linear.app/graphql`) with the key. Everything the workflow does — creating issues, adding comments, updating projects — is attributed to the user who created the key. + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index d972f633cfc..248715d9577 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -7,6 +7,7 @@ "agiloft", "ahrefs", "airtable", + "airtable-service-account", "airweave", "algolia", "amplitude", @@ -15,10 +16,12 @@ "appconfig", "arxiv", "asana", + "asana-service-account", "ashby", "athena", "atlassian-service-account", "attio", + "attio-service-account", "azure_devops", "box", "brandfetch", @@ -27,6 +30,7 @@ "browser_use", "buffer", "calcom", + "calcom-service-account", "calendly", "circleback", "clay", @@ -104,6 +108,7 @@ "greptile", "hex", "hubspot", + "hubspot-service-account", "hubspot-setup", "huggingface", "hunter", @@ -128,6 +133,7 @@ "leadmagic", "lemlist", "linear", + "linear-service-account", "linkedin", "linkup", "linq", @@ -146,12 +152,14 @@ "millionverifier", "mistral_parse", "monday", + "monday-service-account", "mongodb", "mysql", "neo4j", "neverbounce", "new_relic", "notion", + "notion-service-account", "obsidian", "okta", "onedrive", @@ -198,6 +206,7 @@ "sftp", "sharepoint", "shopify", + "shopify-service-account", "similarweb", "sixtyfour", "slack", @@ -219,6 +228,7 @@ "thrive", "tinybird", "trello", + "trello-service-account", "trigger_dev", "twilio", "twilio_sms", @@ -229,7 +239,9 @@ "vanta", "vercel", "wealthbox", + "wealthbox-service-account", "webflow", + "webflow-service-account", "whatsapp", "wikipedia", "wiza", diff --git a/apps/docs/content/docs/en/integrations/monday-service-account.mdx b/apps/docs/content/docs/en/integrations/monday-service-account.mdx new file mode 100644 index 00000000000..6eebb30f653 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/monday-service-account.mdx @@ -0,0 +1,98 @@ +--- +title: monday.com Service Accounts +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: the token doesn't expire on a schedule, no one person's OAuth consent is involved, and the credential survives when an employee leaves. + +## Prerequisites + +Any active monday.com user can generate their own API token — but for production use, create a dedicated integration-user seat first. A token generated from a personal account stops working if that person is deactivated. + + +The token's user must be **active**, **not view-only** (Viewer), and have a **confirmed email address**. Tokens from view-only or unconfirmed users are rejected by the monday.com API with a 403 error even though the token itself is valid. + + +## Setting Up the Token + +### 1. Create a Dedicated Integration User (Recommended) + + + + Have a monday.com admin invite a new member with a shared email like `sim-bot@yourcompany.com` and confirm the email address + + + Add the integration user to every board your workflows will read or write. The token mirrors the user's board access exactly — a board the user can't see is a board the token can't touch + + + +### 2. Generate the Personal API Token + + + + Log in as the integration user, click your **avatar** (bottom-left), and choose **Developers** + + {/* TODO(screenshot): monday.com avatar menu with "Developers" highlighted */} + + + In the Developer Center, open **My access tokens** and click **Show** to reveal the token + + {/* TODO(screenshot): Developer Center "My access tokens" page with the token revealed */} + + + Copy the token. It's a long opaque string — no fixed prefix, so copy it exactly as shown + + + + +monday.com issues **one API token per user**. Regenerating it immediately invalidates the old token — every integration using it breaks at once, with no overlap window. Don't regenerate the token to "get a fresh copy"; reveal and copy the existing one instead. + + +There is no scope picker: personal API tokens carry **all** API permission scopes automatically. What limits the token is the user's own access — board membership, item visibility, and account permissions. + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "monday.com Service Account" and click it + + {/* TODO(screenshot): Integrations page with "monday.com Service Account" in the service list */} + + + Paste the API token and optionally set a display name and description + + {/* TODO(screenshot): Add monday.com Service Account dialog with the API token filled in */} + + + Click **Add Service Account**. Sim verifies the token by querying monday.com's `me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a monday.com block to your workflow. In the credential dropdown, your monday.com service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): monday.com block in a workflow with the service account selected as the credential */} + +The block calls monday.com's single global API endpoint (`api.monday.com/v2`) using the token — there's no per-tenant domain to configure. The token acts as the user it was created by, with that user's board access. + + diff --git a/apps/docs/content/docs/en/integrations/notion-service-account.mdx b/apps/docs/content/docs/en/integrations/notion-service-account.mdx new file mode 100644 index 00000000000..da38fcfa695 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/notion-service-account.mdx @@ -0,0 +1,114 @@ +--- +title: Notion Service Accounts +description: Set up a Notion internal integration with an integration secret to use Notion in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Notion internal integrations let your workflows authenticate to Notion as a workspace-owned bot user instead of through a person's OAuth login. The integration has its own secret, its own capabilities, and its own explicit list of pages and databases it can reach — all managed from the workspace's integration settings. + +This is the recommended way to use Notion in production workflows: the secret doesn't depend on any employee's account, its capabilities are explicit, and access is granted page by page rather than inheriting one person's entire sidebar. + +## Prerequisites + +You need a Notion **workspace owner** to create the integration. Internal integrations are a workspace-level feature — only workspace owners can access the integrations settings and create them. + +## Setting Up the Integration + +### 1. Create the Internal Integration + + + + Open [notion.so/profile/integrations](https://www.notion.so/profile/integrations) and click **New integration** + + {/* TODO(screenshot): Notion integrations page with the "New integration" button */} + + + Give it a name (e.g. `sim-notion-bot`), pick the workspace it belongs to, and keep the type as **Internal** + + + Under **Capabilities**, enable what your workflows need: + + - **Read content** — reading pages and blocks, querying and reading databases, search + - **Insert content** — creating pages and databases, adding database rows, appending blocks + - **Update content** — updating pages and blocks, deleting blocks + - **Comment capabilities** — creating and listing comments + - **User capabilities** — listing and reading workspace users (choose with or without email) + + Enable only what you use — a read-only reporting workflow needs just Read content. + + {/* TODO(screenshot): integration settings Capabilities section with content capabilities enabled */} + + + Save, then copy the **Internal Integration Secret** from the integration's settings page. Secrets issued since September 2024 start with `ntn_`; older `secret_` tokens remain valid. + + {/* TODO(screenshot): integration settings page showing the Internal Integration Secret with the Show/Copy controls */} + + + + +The secret is bearer credentials for everything the integration is connected to. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the secret at rest. + + +### 2. Connect the Integration to Pages and Databases + +This step is the one everyone misses — and without it, the integration can read **nothing**. A brand-new integration has API access but zero page access; every page or database your workflows touch must be explicitly connected. + + + + Open a page or database your workflows need in Notion + + + Click the **•••** menu in the top-right, choose **Connections** (or **Add connections**), and select your integration + + {/* TODO(screenshot): Notion page ••• menu with "Add connections" and the integration selected */} + + + Repeat for each top-level page or database. Connecting a page also grants access to all of its sub-pages. + + + + +A valid secret with no page connections validates fine in Sim but returns `404 object_not_found` on every real call. If a Notion block reports that a page or database wasn't found — and you're sure the ID is right — the page isn't connected to the integration. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Notion Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Notion Service Account" in the service list */} + + + Paste the **Internal integration secret**, and optionally set a display name and description + + {/* TODO(screenshot): Add Notion Service Account dialog with the internal integration secret filled in */} + + + Click **Add Service Account**. Sim verifies the secret by calling Notion's bot-user endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +## Using the Service Account in Workflows + +Add a Notion block to your workflow. In the credential dropdown, your Notion service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Notion block in a workflow with the Notion service account selected as the credential */} + +The block calls `api.notion.com` using the integration secret — exactly the same requests as the OAuth flow, so every Notion tool works unchanged, limited to the capabilities you enabled and the pages you connected. + + diff --git a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx new file mode 100644 index 00000000000..658d843f5bf --- /dev/null +++ b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx @@ -0,0 +1,113 @@ +--- +title: Shopify Service Accounts +description: Set up a Shopify custom app with an Admin API access token to use Shopify in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Shopify custom apps let your workflows authenticate to a store with an Admin API access token (`shpat_...`) — a permanent, store-bound credential that belongs to the app, not to any employee's login. You choose exactly which Admin API scopes the app gets, and the token never expires. + +This is the recommended way to use Shopify in production workflows: no OAuth consent to go stale, scopes are explicit and auditable, and access is revoked by uninstalling the app — without touching anyone's account. + +## Prerequisites + +You need store owner or staff permissions that allow managing apps on the store. You'll also need your store's permanent domain (`your-store.myshopify.com`) — Sim asks for it alongside the token. + + +Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly. + + +## Setting Up the Custom App + +### 1. Create the App and Grant Scopes + + + + Open the [Dev Dashboard](https://dev.shopify.com/), create a new app (e.g. `sim-workflows`), and connect it to your store + + {/* TODO(screenshot): Shopify Dev Dashboard with a new custom app created */} + + + Configure the app's **Admin API access scopes**. The set Sim's Shopify tools use is: + + ``` + write_products + write_orders + write_customers + write_inventory + read_locations + write_merchant_managed_fulfillment_orders + ``` + + In Shopify, `write_X` includes `read_X`, so these six cover product, collection, order, fulfillment, customer, inventory, and location operations. Grant fewer if your workflows only read — e.g. `read_products` alone is enough for product and collection lookups + + {/* TODO(screenshot): Admin API access scopes picker with the six scopes selected */} + + + Install the app on your store to generate the Admin API access token + + + +### 2. Copy the Admin API Access Token + + + + Reveal the **Admin API access token** — it starts with `shpat_` + + {/* TODO(screenshot): app's API credentials page with the shpat_ token revealed */} + + + Copy it immediately. 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 + + + + +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 channel token — neither will work. If your Dev Dashboard app only shows a client ID and secret, complete the app's store installation to obtain the permanent `shpat_` token. + + +### 3. Find Your Store Domain + +Use the permanent `.myshopify.com` domain — for example, `your-store.myshopify.com` — not your custom storefront domain. You can find it in your Shopify admin URL or under **Settings** → **Domains**. + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Shopify Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Shopify Service Account" in the service list */} + + + Paste the Admin API access token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description + + {/* TODO(screenshot): Add Shopify Service Account dialog with token and store domain filled in */} + + + Click **Add Service Account**. Sim verifies the token by querying your store's `shop` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The token and store domain are encrypted before being stored. + +## Using the Service Account in Workflows + +Add a Shopify block to your workflow. In the credential dropdown, your Shopify service account appears alongside any OAuth credentials. Select it and configure the block as you normally would — the store domain stored with the credential is used automatically. + +{/* TODO(screenshot): Shopify block in a workflow with the service account selected as the credential */} + +The block calls your store's Admin API (`your-store.myshopify.com/admin/api/...`) using the token, with exactly the scopes you granted the app. + + diff --git a/apps/docs/content/docs/en/integrations/trello-service-account.mdx b/apps/docs/content/docs/en/integrations/trello-service-account.mdx new file mode 100644 index 00000000000..20734d20bf7 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/trello-service-account.mdx @@ -0,0 +1,95 @@ +--- +title: Trello Service Accounts +description: Mint a Trello API token bound to Sim's API key to use Trello in Sim workflows without OAuth +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Trello service account credentials let your workflows authenticate to Trello with a pasted API token instead of a personal OAuth connection. The token belongs to a Trello member — ideally a dedicated bot account — so workflows keep running even when individual teammates leave or revoke their own connections. + + +Trello tokens are bound to the API key that authorized them. A token generated with your own Trello Power-Up key will **not** work in Sim — Trello rejects it with `401 invalid token`. The token must be minted through Trello's authorize URL using the API key of the Sim deployment you're connecting to. Follow the steps below exactly. + + +## Prerequisites + +You need a Trello account that has access to the boards your workflows will read and write. For production workflows, create a dedicated bot Trello account (e.g. `sim-bot@yourcompany.com`), invite it to the relevant boards, and mint the token while logged in as that account — the credential then survives any individual employee leaving. + +## Generating the API Token + +Trello doesn't have a token-creation settings page. Tokens are minted by visiting an authorize URL in the browser while logged in to the Trello account that should own the token: + +``` +https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&name=Sim&key= +``` + + +The `key` parameter must be the Trello API key of the Sim deployment: + +- **Sim's hosted cloud** — the full authorize link with the key already filled in is provided in-product when you add the credential (or via support). Don't substitute your own key. +- **Self-hosted Sim** — substitute the value of your deployment's `TRELLO_API_KEY` environment variable. Trello API keys are public-by-design identifiers (not secrets); yours is shown at [trello.com/power-ups/admin](https://trello.com/power-ups/admin) under your Power-Up's API key page. + + + + + Log in to Trello as the account that should own the token (the bot account, for production) + + + Open the authorize URL above in the browser. Trello shows a consent page describing the access being granted + + {/* TODO(screenshot): Trello authorize consent page showing "Sim" requesting read/write access with never expiration */} + + + Click **Allow**. Trello displays the token on-screen — copy it + + {/* TODO(screenshot): Trello token display page after clicking Allow, with the token string shown */} + + + + +Keep `expiration=never` and `scope=read,write` in the URL exactly as shown. A token minted with a shorter expiration (`1hour`, `1day`, `30days`) will validate today and silently die later. A read-only token (`scope=read`) also validates — but every Trello write tool in your workflows (create card, update card, add comment, and so on) will then fail at runtime. + + +Newer Trello tokens start with `ATTA`; older ones are 64-character hex strings. Both work — Sim doesn't enforce a format. + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Trello Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Trello Service Account" in the service list */} + + + Paste the API token, and optionally set a display name and description + + {/* TODO(screenshot): Add Trello Service Account dialog with the API token filled in */} + + + Click **Add Service Account**. Sim verifies the token by calling Trello's `/1/members/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a Trello block to your workflow. In the credential dropdown, your Trello service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Trello block in a workflow with the Trello service account selected as the credential */} + +The block calls Trello's API (`api.trello.com`) with the token, paired server-side with the deployment's API key. The credential acts as the Trello member that minted the token, with access to exactly the boards that member can see. + + diff --git a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx new file mode 100644 index 00000000000..a8e2ffc5a76 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx @@ -0,0 +1,80 @@ +--- +title: Wealthbox Service Accounts +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. Tokens are long-lived — they stay valid until you revoke them — so workflows keep running without anyone renewing an OAuth consent. + +## Prerequisites + +API access must be enabled for your Wealthbox account — it isn't available on every plan. If you don't see an **API Access** section in your Wealthbox settings, contact Wealthbox support to have it enabled before continuing. + + +Wealthbox tokens are tied to the user who creates them and carry that user's permissions. For production workflows, create the token from a dedicated service login with adequate record visibility rather than a personal account — the credential then survives any individual employee leaving. + + +## Creating the API Access Token + + + + Log in to Wealthbox, open **Settings** (the three-dot menu), and go to **API Access** (direct link: [app.crmworkspace.com/settings/access_tokens](https://app.crmworkspace.com/settings/access_tokens)) + + {/* TODO(screenshot): Wealthbox settings page with the API Access section open */} + + + Click **Create Access Token**, give it a label (e.g. `sim-workflows`), and save + + {/* TODO(screenshot): Wealthbox Create Access Token dialog with a label filled in */} + + + Copy the token. It has no expiry — it remains valid until you revoke it from this same page. + + + + +The token carries the full permissions of the user who created it — there is no scope selection. Treat it like a password: do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Wealthbox Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Wealthbox Service Account" in the service list */} + + + Paste the API access token, and optionally set a display name and description + + {/* TODO(screenshot): Add Wealthbox Service Account dialog with the API access token filled in */} + + + Click **Add Service Account**. Sim verifies the token by calling Wealthbox's `/v1/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a Wealthbox block to your workflow. In the credential dropdown, your Wealthbox service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Wealthbox block in a workflow with the Wealthbox service account selected as the credential */} + +The block calls Wealthbox's API (`api.crmworkspace.com`) with the token. The credential acts as the Wealthbox user who created the token — contacts, tasks, and notes the workflows touch are the ones that user can see under Wealthbox's permission settings. + + diff --git a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx new file mode 100644 index 00000000000..b0ed6229e25 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx @@ -0,0 +1,89 @@ +--- +title: Webflow Service Accounts +description: Set up a Webflow site API token with the right scopes to use Webflow CMS in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Webflow site tokens let your workflows authenticate to a single Webflow site with a token you generate from the site's settings — no OAuth consent, no user account attached. You pick the scopes at creation, and the token is bound to exactly that site. + +This is the recommended way to use Webflow in production workflows: the credential belongs to the site rather than to a person, its scopes are explicit, and it can be revoked from site settings without touching anyone's account. + +## Prerequisites + +Only **site administrators** can generate site tokens. Each site allows a maximum of **5 tokens** — if you're at the cap, revoke an unused one before creating a token for Sim. + +## Setting Up the Site Token + + + + Open your site's **Site settings** and go to **Apps & integrations** → **API access** + + {/* TODO(screenshot): Webflow Site settings "Apps & integrations" page with the API access section highlighted */} + + + Click **Generate API token** and give it a recognizable name (e.g. `sim-workflows`) + + + Select the scopes the token needs. The minimum set for Sim's Webflow blocks is: + + ``` + Sites: Read + CMS: Read and write + ``` + + `Sites: Read` (`sites:read`) is required for Sim to verify the token when you connect it. `CMS: Read and write` (`cms:read` + `cms:write`) covers Sim's Webflow tools — listing, getting, creating, updating, and deleting CMS items. Add other scopes only if you need them + + {/* TODO(screenshot): Webflow token scope picker with Sites read and CMS read/write selected */} + + + Copy the token when it's shown. Webflow only displays it once — if you close the dialog, you'll have to generate a new token. + + + + +Site tokens expire after **365 consecutive days of inactivity**. Any API call resets the clock, so a workflow that runs regularly keeps its token alive indefinitely — but a workflow that sits dormant for a year will start failing silently with authentication errors. If a workflow runs rarely, set a calendar reminder to run it (or any Webflow block) at least once a year, or check the credential periodically in Sim. + + +## Adding the Service Account to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Webflow Service Account" and click it + + {/* TODO(screenshot): Integrations page with "Webflow Service Account" in the service list */} + + + Paste the site API token and optionally set a display name and description + + {/* TODO(screenshot): Add Webflow Service Account dialog with the site API token filled in */} + + + Click **Add Service Account**. Sim verifies the token by listing the sites it can access — if it fails, you'll see a specific error explaining what went wrong. A token created without the `sites:read` scope fails this check even if its CMS scopes are correct. + + + +The token is encrypted before being stored, along with the site it grants access to. + +## Using the Service Account in Workflows + +Add a Webflow block to your workflow. In the credential dropdown, your Webflow service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Webflow block in a workflow with the service account selected as the credential */} + +The block calls Webflow's Data API (`api.webflow.com/v2/...`) using the token. It can only reach collections and items belonging to the token's site — pointing a block at a different site's collection fails with an access error. + + diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 9d539bcd39a..092709d9d93 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -325,25 +325,19 @@ export async function getAtlassianServiceAccountSecret( } /** - * For Atlassian service accounts, the API token IS the access token — - * blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`. - * No exchange or refresh is needed; we just decrypt and return the raw token. + * Result of resolving a `service_account` credential into a usable token. For + * Atlassian and the token-paste providers, the stored token IS the access + * token — no exchange or refresh is needed; Google mints a short-lived token + * via the JWT-bearer flow instead. */ export interface ServiceAccountTokenResult { accessToken: string /** Atlassian only — the resolved Jira/Confluence cloud id. */ cloudId?: string - /** Atlassian only — the site domain. */ + /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ domain?: string } -/** - * Single dispatch point for turning a `service_account` credential into an - * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the - * `POST /api/auth/oauth/token` route go through here, so a new service-account - * provider is one edit and an unknown provider fails loudly instead of silently - * attempting a Google JWT. - */ /** * Loads and parses the decrypted secret blob for a token service-account * credential (pasted long-lived provider token). Throws if the credential is @@ -402,6 +396,13 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record +): Promise { + const error = await promise.then( + () => { + throw new Error('expected a TokenServiceAccountValidationError to be thrown') + }, + (e: unknown) => e + ) + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + return error as TokenServiceAccountValidationError +} + +describe('token service-account error helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('fetchProvider', () => { + it('maps a rejected fetch to provider_unavailable 502', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')) + + const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self')) + + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + expect(error.logDetail).toEqual({ step: 'self', reason: 'network error reaching provider' }) + }) + + it('never includes the URL in logDetail', async () => { + mockFetch.mockRejectedValue(new Error(`ECONNREFUSED ${PROVIDER_URL}`)) + + const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self')) + + expect(JSON.stringify(error.logDetail)).not.toContain(PROVIDER_URL) + expect(JSON.stringify(error.logDetail)).not.toContain('example-provider') + }) + + it('returns the response untouched when fetch resolves', async () => { + const res = new Response('ok', { status: 200 }) + mockFetch.mockResolvedValue(res) + + await expect(fetchProvider(PROVIDER_URL, {}, 'self')).resolves.toBe(res) + }) + }) + + describe('parseProviderJson', () => { + it('maps a non-JSON body to provider_unavailable 502', async () => { + const res = new Response('502 Bad Gateway', { status: 200 }) + + const error = await expectValidationError(parseProviderJson(res, 'self')) + + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(502) + expect(error.logDetail).toEqual({ + step: 'self', + reason: 'provider returned a non-JSON response body', + }) + }) + + it('returns the parsed body for valid JSON', async () => { + const res = new Response(JSON.stringify({ id: 'acct-1' }), { status: 200 }) + + await expect(parseProviderJson(res, 'self')).resolves.toEqual({ id: 'acct-1' }) + }) + }) + + describe('throwForProviderResponse', () => { + it.each([401, 403])( + 'maps %i to invalid_credentials with the response status', + async (status) => { + const res = new Response('denied', { status }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.code).toBe('invalid_credentials') + expect(error.status).toBe(status) + } + ) + + it.each([429, 500, 503])( + 'maps %i to provider_unavailable with the response status', + async (status) => { + const res = new Response('provider trouble', { status }) + + const error = await expectValidationError(throwForProviderResponse(res, 'self')) + + expect(error.code).toBe('provider_unavailable') + expect(error.status).toBe(status) + } + ) + + it('returns without throwing on a 2xx response', async () => { + const res = new Response('ok', { status: 200 }) + + await expect(throwForProviderResponse(res, 'self')).resolves.toBeUndefined() + }) + }) + + describe('readProviderErrorSnippet', () => { + it('truncates the body to 500 characters', async () => { + const res = new Response('x'.repeat(2000), { status: 500 }) + + const snippet = await readProviderErrorSnippet(res) + + expect(snippet).toBe(`${'x'.repeat(500)}...`) + }) + + it('never throws on an unreadable body', async () => { + const res = new Response('gone', { status: 500 }) + vi.spyOn(res, 'text').mockRejectedValue(new Error('body stream already read')) + + await expect(readProviderErrorSnippet(res)).resolves.toBe('') + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index 42086788def..c0a2ed16d1d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -4,11 +4,13 @@ import { 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, @@ -56,7 +58,10 @@ export type TokenServiceAccountValidator = ( * 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 = { +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, @@ -74,7 +79,9 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record { 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'] }) + jsonResponse(200, { + id: 'usrABC123', + email: 'svc@example.com', + scopes: ['data.records:read'], + }) ) const result = await validateAirtableServiceAccount({ apiToken: 'pat123.secret' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index ef696b6c166..ccab65691eb 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index f3e53ac2e66..c138258ee35 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index d9e971c2220..ff61013fd2b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -73,6 +73,18 @@ describe('validateAttioServiceAccount', () => { ) }) + 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)) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index eea2f55c156..1969439c4ac 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -22,7 +22,10 @@ interface AttioSelfResponse { * 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. + * 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 @@ -37,6 +40,12 @@ export async function validateAttioServiceAccount( }, 'self' ) + if (res.status === 400) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'self', + reason: 'token was not recognised (HTTP 400)', + }) + } await throwForProviderResponse(res, 'self') const self = await parseProviderJson(res, 'self') diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index b3a5f1d9a6b..c536bb43b1e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 8d2b0b9dc73..cfd597dfd77 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index 59e17044369..3007d161370 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -58,7 +58,9 @@ describe('validateLinearServiceAccount', () => { it('throws invalid_credentials on 401', async () => { mockFetch.mockResolvedValueOnce( jsonResponse(401, { - errors: [{ message: 'Authentication required', extensions: { type: 'authentication_error' } }], + errors: [ + { message: 'Authentication required', extensions: { type: 'authentication_error' } }, + ], }) ) @@ -76,7 +78,9 @@ describe('validateLinearServiceAccount', () => { }) ) - await expect(validateLinearServiceAccount({ apiToken: 'lin_api_revoked' })).rejects.toMatchObject({ + await expect( + validateLinearServiceAccount({ apiToken: 'lin_api_revoked' }) + ).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', code: 'invalid_credentials', status: 200, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 1ff051b986d..ea4e297f374 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,8 +1,8 @@ import { - TokenServiceAccountValidationError, fetchProvider, parseProviderJson, readProviderErrorSnippet, + TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index 2e804095549..088c1859cb6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -105,6 +105,40 @@ describe('validateMondayServiceAccount', () => { 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 })) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 6030a229a5a..b3c82ffd47a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -24,19 +24,24 @@ interface MondayValidationBody { error_message?: string } -const SERVER_SIDE_ERROR_CODE = /internal|server_error|unavailable/i +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`, per the - * monday API error documentation. + * (>= 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 { - const extensions = body.errors?.[0]?.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) + 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) + }) } /** @@ -110,10 +115,14 @@ export async function validateMondayServiceAccount( if (account?.slug) { storedMetadata.accountSlug = account.slug } + const auditMetadata: Record = {} + if (accountId) { + auditMetadata.mondayAccountId = accountId + } return { displayName: account?.name || me.name || me.email || `monday user ${userId}`, - auditMetadata: { mondayAccountId: accountId }, + auditMetadata, storedMetadata, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts index 47e6823bb94..6d57b5ccaa6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -73,9 +73,7 @@ describe('validateNotionServiceAccount', () => { }) it('throws invalid_credentials on 401', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse(401, { object: 'error', code: 'unauthorized' }) - ) + mockFetch.mockResolvedValueOnce(jsonResponse(401, { object: 'error', code: 'unauthorized' })) await expect(validateNotionServiceAccount({ apiToken: 'ntn_bad' })).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index a69ace4d6f4..4321ba5f3c4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index bc038737529..67b2efb559a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -112,9 +112,7 @@ describe('validateShopifyServiceAccount', () => { }) it('throws provider_unavailable when a 200 body carries GraphQL errors', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse(200, { errors: [{ message: 'Internal error' }] }) - ) + mockFetch.mockResolvedValueOnce(jsonResponse(200, { errors: [{ message: 'Internal error' }] })) await expect( validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 63bd1a42182..cbe651d5135 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -1,8 +1,8 @@ import { - TokenServiceAccountValidationError, fetchProvider, parseProviderJson, readProviderErrorSnippet, + TokenServiceAccountValidationError, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 2ac940a8261..209a43e8b2e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -1,9 +1,9 @@ import { env } from '@/lib/core/config/env' import { - TokenServiceAccountValidationError, fetchProvider, parseProviderJson, readProviderErrorSnippet, + TokenServiceAccountValidationError, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index 787f7d2b6ca..94e85821f33 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -2,8 +2,8 @@ import { fetchProvider, parseProviderJson, readProviderErrorSnippet, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, @@ -91,8 +91,7 @@ export async function validateWealthboxServiceAccount( const me = await parseProviderJson(res, 'me') const displayName = me.name || me.email || 'Wealthbox account' - const userId = - typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined + const userId = typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined const email = me.email || me.current_user?.email const auditMetadata: Record = {} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index cfda87a1910..034558532b2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -1,8 +1,8 @@ import { fetchProvider, parseProviderJson, - throwForProviderResponse, TokenServiceAccountValidationError, + throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { TokenServiceAccountFields, diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 7f6af144aeb..34712123df8 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,7 +1,7 @@ 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 { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { getServiceConfigByServiceId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' From 77f9c00b566b4a39c20f37be3350559771bd5cc2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:13:03 -0700 Subject: [PATCH 04/10] 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) --- .../en/integrations/asana-service-account.mdx | 6 ++--- .../en/integrations/attio-service-account.mdx | 4 ++-- .../integrations/hubspot-service-account.mdx | 12 +++++----- .../integrations/linear-service-account.mdx | 2 +- .../integrations/monday-service-account.mdx | 14 ++++++------ .../integrations/notion-service-account.mdx | 10 ++++----- .../integrations/shopify-service-account.mdx | 22 ++++++++----------- .../integrations/trello-service-account.mdx | 4 ++-- .../wealthbox-service-account.mdx | 12 +++++----- .../integrations/webflow-service-account.mdx | 4 ++-- .../token-service-accounts/descriptors.ts | 7 +++--- 11 files changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/asana-service-account.mdx b/apps/docs/content/docs/en/integrations/asana-service-account.mdx index ce3a3bfd33a..c82862d583e 100644 --- a/apps/docs/content/docs/en/integrations/asana-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/asana-service-account.mdx @@ -40,7 +40,7 @@ You need an Asana **super admin** on an **Enterprise** or **Enterprise+** plan. {/* TODO(screenshot): service account permission picker with "Full Permissions" selected */} - Copy the token when it's shown. Asana only displays it once — if you lose it, you'll have to regenerate it, which immediately invalidates the old token. + 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. @@ -59,7 +59,7 @@ If you're not on an Enterprise plan, a personal access token works identically o {/* TODO(screenshot): Asana developer console "My apps" page with "Create new token" button */} - Click **Create new token**, give it a description, and agree to the API terms + Click **+ Create new token**, give it a name that describes its use (e.g. `Sim Integration`), and click **Create token** Copy the token immediately — Asana only shows it once. @@ -108,7 +108,7 @@ The block calls the Asana API (`app.asana.com/api/1.0`) with the token as a stan { 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: "Regenerating a service account token in the Admin Console rotates it immediately — the old token stops working the moment the new one is issued. Regenerate, then update the credential in Sim with the new token right away." }, + { 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." }, ]} /> diff --git a/apps/docs/content/docs/en/integrations/attio-service-account.mdx b/apps/docs/content/docs/en/integrations/attio-service-account.mdx index 9480a7cc138..de062d9a782 100644 --- a/apps/docs/content/docs/en/integrations/attio-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/attio-service-account.mdx @@ -18,9 +18,9 @@ You need an Attio **workspace admin**. Only admins can create and manage access - Open **Workspace settings** in Attio and go to **Developers** → **API keys** + Open **Workspace settings** in Attio (dropdown beside your workspace name) and go to the **Developers** tab - {/* TODO(screenshot): Attio workspace settings with Developers → API keys highlighted */} + {/* TODO(screenshot): Attio workspace settings with the Developers tab highlighted */} Click **New access token** and give it a name (e.g. `Sim Integration`) diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx index 1e9e53022a7..e1d5bb2587c 100644 --- a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -16,7 +16,7 @@ This is the recommended way to use HubSpot in production workflows: the token do You need a HubSpot **super admin** to create the private app. Private apps can only be created and managed by super admins in the portal. -The token is tied to the super admin who created the private app. If that user is later removed from the portal (or loses super admin), some API calls — notably associations — start failing with `USER_DOES_NOT_HAVE_PERMISSIONS`. Create the app from an account you expect to keep, and rotate the token if the creator ever leaves. +The token is tied to the super admin who created the private app. If that user is later removed from the portal (or loses super admin), some API calls start failing with `USER_DOES_NOT_HAVE_PERMISSIONS` — association calls are commonly reported. Create the app from an account you expect to keep, and rotate the token if the creator ever leaves. ## Setting Up the Private App @@ -25,16 +25,16 @@ The token is tied to the super admin who created the private app. If that user i - In HubSpot, open **Settings** → **Integrations** → **Legacy apps** → **Private apps** + In HubSpot, navigate to **Development** in the left sidebar, then **Legacy apps** - {/* TODO(screenshot): HubSpot settings navigation showing Integrations → Legacy apps → Private apps */} + {/* TODO(screenshot): HubSpot navigation showing Development → Legacy apps */} - HubSpot recently relabeled private apps as "Legacy apps" in its settings navigation. The tokens themselves remain fully supported — only the menu label changed. If you don't see "Legacy apps", look for "Private Apps" directly under Integrations. + HubSpot moved private apps under a new **Development** area and relabeled them "Legacy apps". The tokens themselves remain fully supported — only the navigation changed. If you don't see **Development**, look for **Private Apps** under **Settings** → **Integrations** in older portals. - Click **Create a private app** and give it a name (e.g. `sim-hubspot-bot`) and description + Click **Create legacy app** in the top right, select **Private**, and give it a name (e.g. `sim-hubspot-bot`) and description Open the **Scopes** tab and select the scopes your workflows need (see the list below) @@ -136,6 +136,6 @@ Prefer the 7-day option: rotate in HubSpot, paste the new token into the credent { 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 — no new token is needed, scope changes apply to the existing token." }, { 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 — associations in particular — can start failing with USER_DOES_NOT_HAVE_PERMISSIONS. Have another super admin rotate the token (or recreate the app) and update the credential in Sim." }, + { 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." }, ]} /> diff --git a/apps/docs/content/docs/en/integrations/linear-service-account.mdx b/apps/docs/content/docs/en/integrations/linear-service-account.mdx index da5d943cc48..0af37be5f76 100644 --- a/apps/docs/content/docs/en/integrations/linear-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/linear-service-account.mdx @@ -20,7 +20,7 @@ A Linear account that is allowed to create API keys. Workspace admins can disabl - Log in as the service user, open Linear **Settings**, and go to **Security & access** → **API keys** + 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 */} diff --git a/apps/docs/content/docs/en/integrations/monday-service-account.mdx b/apps/docs/content/docs/en/integrations/monday-service-account.mdx index 6eebb30f653..305ea08d3bc 100644 --- a/apps/docs/content/docs/en/integrations/monday-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/monday-service-account.mdx @@ -10,11 +10,11 @@ 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: the token doesn't expire on a schedule, no one person's OAuth consent is involved, and the credential survives when an employee leaves. +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 -Any active monday.com user can generate their own API token — but for production use, create a dedicated integration-user seat first. A token generated from a personal account stops working if that person is deactivated. +Admins and members can generate their own API token (guests and viewers cannot) — for production use, create a dedicated integration-user seat first. A token generated from a personal account stops working if that person is deactivated. The token's user must be **active**, **not view-only** (Viewer), and have a **confirmed email address**. Tokens from view-only or unconfirmed users are rejected by the monday.com API with a 403 error even though the token itself is valid. @@ -37,17 +37,17 @@ The token's user must be **active**, **not view-only** (Viewer), and have a **co - Log in as the integration user, click your **avatar** (bottom-left), and choose **Developers** + Log in as the integration user, click your **profile picture** (top-right), and choose **Developers** - {/* TODO(screenshot): monday.com avatar menu with "Developers" highlighted */} + {/* TODO(screenshot): monday.com profile-picture menu with "Developers" highlighted */} - In the Developer Center, open **My access tokens** and click **Show** to reveal the token + In the Developer Center, open the **API token** tab and click **Show** to reveal the token - {/* TODO(screenshot): Developer Center "My access tokens" page with the token revealed */} + {/* TODO(screenshot): Developer Center "API token" tab with the token revealed */} - Copy the token. It's a long opaque string — no fixed prefix, so copy it exactly as shown + Copy the token exactly as shown — monday.com doesn't document a fixed token format diff --git a/apps/docs/content/docs/en/integrations/notion-service-account.mdx b/apps/docs/content/docs/en/integrations/notion-service-account.mdx index da38fcfa695..4a692f8ea7b 100644 --- a/apps/docs/content/docs/en/integrations/notion-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/notion-service-account.mdx @@ -21,12 +21,12 @@ You need a Notion **workspace owner** to create the integration. Internal integr - Open [notion.so/profile/integrations](https://www.notion.so/profile/integrations) and click **New integration** + Open [notion.so/profile/integrations](https://www.notion.so/profile/integrations) (in Notion: **Settings** → **Connections** → **Develop or manage connections**) and create a new integration. Notion is renaming integrations to "connections" — newer workspaces see this at app.notion.com/developers/connections with a **Create a new connection** button. - {/* TODO(screenshot): Notion integrations page with the "New integration" button */} + {/* TODO(screenshot): Notion integrations page with the create-integration button */} - Give it a name (e.g. `sim-notion-bot`), pick the workspace it belongs to, and keep the type as **Internal** + Give it a name (e.g. `sim-notion-bot`), pick the workspace it belongs to, and make sure you are creating an **internal** integration (the default; newer UIs list it under **Internal connections**) Under **Capabilities**, enable what your workflows need: @@ -42,7 +42,7 @@ You need a Notion **workspace owner** to create the integration. Internal integr {/* TODO(screenshot): integration settings Capabilities section with content capabilities enabled */} - Save, then copy the **Internal Integration Secret** from the integration's settings page. Secrets issued since September 2024 start with `ntn_`; older `secret_` tokens remain valid. + 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 */} @@ -109,6 +109,6 @@ The block calls `api.notion.com` using the integration secret — exactly the sa { 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 settings page has a 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: "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." }, ]} /> diff --git a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx index 658d843f5bf..3b325ea8455 100644 --- a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx @@ -47,25 +47,21 @@ Shopify no longer allows creating new custom apps from the store admin — that {/* TODO(screenshot): Admin API access scopes picker with the six scopes selected */} - Install the app on your store to generate the Admin API access token + Install the app on your store, then obtain the `shpat_` token as described in the next section — the flow differs by app type -### 2. Copy the Admin API Access Token +### 2. Get the Admin API Access Token - - - Reveal the **Admin API access token** — it starts with `shpat_` +How you obtain the `shpat_` token depends on how the app was created: - {/* TODO(screenshot): app's API credentials page with the shpat_ token revealed */} - - - Copy it immediately. 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 - - +- **Legacy admin-created custom apps** (created from the store admin before Shopify removed that path) show the **Admin API access token** — it starts with `shpat_` — once on the app's API credentials page. Reveal it, copy it immediately, and paste it into Sim. For legacy admin-created custom apps, Shopify shows the token only once, at creation — if you lose it, you have to reinstall or recreate the app to get a new one. +- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Complete an OAuth (authorization code grant) install with the app's client ID and secret to obtain an offline `shpat_` access token programmatically. The Dev Dashboard's client-credentials tokens are different — they expire after 24 hours and cannot be stored in Sim. + +{/* TODO(screenshot): legacy custom app's API credentials page with the shpat_ token revealed */} -Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` is a channel token — neither will work. If your Dev Dashboard app only shows a client ID and secret, complete the app's store installation to obtain the permanent `shpat_` token. +Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` is a custom app access token from older Partner-created custom apps — neither will work. If your Dev Dashboard app only shows a client ID and secret, that is expected: Dev Dashboard apps issue tokens via OAuth, not a UI reveal (see the setup steps above). ### 3. Find Your Store Domain @@ -108,6 +104,6 @@ The block calls your store's Admin API (`your-store.myshopify.com/admin/api/...` { 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 in the Dev Dashboard and reinstall it to apply the change — the token keeps working with the new scopes." }, + { 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." }, ]} /> diff --git a/apps/docs/content/docs/en/integrations/trello-service-account.mdx b/apps/docs/content/docs/en/integrations/trello-service-account.mdx index 20734d20bf7..652b9754eb8 100644 --- a/apps/docs/content/docs/en/integrations/trello-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/trello-service-account.mdx @@ -87,9 +87,9 @@ The block calls Trello's API (`api.trello.com`) with the token, paired server-si diff --git a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx index a8e2ffc5a76..44b8e43fac0 100644 --- a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx @@ -7,11 +7,11 @@ 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. Tokens are long-lived — they stay valid until you revoke them — so workflows keep running without anyone renewing an OAuth consent. +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 -API access must be enabled for your Wealthbox account — it isn't available on every plan. If you don't see an **API Access** section in your Wealthbox settings, contact Wealthbox support to have it enabled before continuing. +Wealthbox trial accounts cannot use the API (calls return `402 Trial expired`). If you don't see an **API Access** section in your Wealthbox settings, contact Wealthbox support before continuing. Wealthbox tokens are tied to the user who creates them and carry that user's permissions. For production workflows, create the token from a dedicated service login with adequate record visibility rather than a personal account — the credential then survives any individual employee leaving. @@ -31,7 +31,7 @@ Wealthbox tokens are tied to the user who creates them and carry that user's per {/* TODO(screenshot): Wealthbox Create Access Token dialog with a label filled in */} - Copy the token. It has no expiry — it remains valid until you revoke it from this same page. + Copy the token. Wealthbox doesn't document an expiry for API access tokens; you can revoke it from this same page at any time. @@ -73,8 +73,8 @@ The block calls Wealthbox's API (`api.crmworkspace.com`) with the token. The cre diff --git a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx index b0ed6229e25..623a4cd2373 100644 --- a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx @@ -31,11 +31,11 @@ Only **site administrators** can generate site tokens. Each site allows a maximu Select the scopes the token needs. The minimum set for Sim's Webflow blocks is: ``` - Sites: Read + Sites: Read-only CMS: Read and write ``` - `Sites: Read` (`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 + 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 */} diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index fad1b99d2bc..567d8d6fd66 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -127,7 +127,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/notion-service-account', helpText: - 'Remember to connect the integration to the pages and databases it should access — a valid secret with no page connections can read nothing.', + '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, @@ -217,7 +217,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', helpText: - 'Shopify reveals the Admin API token only once at app creation, and new custom apps are created from the Dev Dashboard or CLI. The token is store-bound and does not expire.', + '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, @@ -283,7 +283,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< }, ], docsUrl: 'https://docs.sim.ai/integrations/wealthbox-service-account', - helpText: 'API access must be enabled for your Wealthbox account before tokens can be created.', + helpText: + 'Trial accounts cannot use the Wealthbox API; contact Wealthbox support if API Access is missing from your Settings.', }, } From dda7dad5b311a218d2941d6c9f7c47c28cb0d0c4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:19:06 -0700 Subject: [PATCH 05/10] improvement(credentials): vendor-accurate credential nouns on connect surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- .../integrations/airtable-service-account.mdx | 2 +- .../en/integrations/asana-service-account.mdx | 2 +- .../en/integrations/attio-service-account.mdx | 2 +- .../en/integrations/calcom-service-account.mdx | 2 +- .../integrations/hubspot-service-account.mdx | 2 +- .../en/integrations/linear-service-account.mdx | 2 +- .../en/integrations/monday-service-account.mdx | 2 +- .../en/integrations/notion-service-account.mdx | 2 +- .../integrations/shopify-service-account.mdx | 2 +- .../en/integrations/trello-service-account.mdx | 2 +- .../integrations/wealthbox-service-account.mdx | 2 +- .../integrations/webflow-service-account.mdx | 2 +- .../[block]/integration-block-detail.tsx | 12 +++++++++++- .../token-service-account-modal.tsx | 8 ++++---- .../token-service-accounts/descriptors.ts | 18 ++++++++++++++++++ 15 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx index 18b95f1eff6..5f3ce7ec686 100644 --- a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Airtable Service Accounts +title: Airtable Personal Access Tokens description: Set up an Airtable personal access token with scoped base access to use Airtable in Sim workflows --- diff --git a/apps/docs/content/docs/en/integrations/asana-service-account.mdx b/apps/docs/content/docs/en/integrations/asana-service-account.mdx index c82862d583e..aa37cbac920 100644 --- a/apps/docs/content/docs/en/integrations/asana-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/asana-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Asana Service Accounts +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 --- diff --git a/apps/docs/content/docs/en/integrations/attio-service-account.mdx b/apps/docs/content/docs/en/integrations/attio-service-account.mdx index de062d9a782..2e6ffc26996 100644 --- a/apps/docs/content/docs/en/integrations/attio-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/attio-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Attio Service Accounts +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 --- diff --git a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx index c786e5a2f82..37c18462d7b 100644 --- a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Cal.com Service Accounts +title: Cal.com API Keys description: Set up a Cal.com API key to use Cal.com in Sim workflows without OAuth --- diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx index e1d5bb2587c..391df95d5cb 100644 --- a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -1,5 +1,5 @@ --- -title: HubSpot Service Accounts +title: HubSpot Private App Tokens description: Set up a HubSpot private app with a scoped access token to use HubSpot in Sim workflows --- diff --git a/apps/docs/content/docs/en/integrations/linear-service-account.mdx b/apps/docs/content/docs/en/integrations/linear-service-account.mdx index 0af37be5f76..2c5e31cf176 100644 --- a/apps/docs/content/docs/en/integrations/linear-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/linear-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Linear Service Accounts +title: Linear API Keys description: Connect Linear to Sim with a personal API key — ideally created from a dedicated service user for production workflows --- diff --git a/apps/docs/content/docs/en/integrations/monday-service-account.mdx b/apps/docs/content/docs/en/integrations/monday-service-account.mdx index 305ea08d3bc..075eeccd917 100644 --- a/apps/docs/content/docs/en/integrations/monday-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/monday-service-account.mdx @@ -1,5 +1,5 @@ --- -title: monday.com Service Accounts +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 --- diff --git a/apps/docs/content/docs/en/integrations/notion-service-account.mdx b/apps/docs/content/docs/en/integrations/notion-service-account.mdx index 4a692f8ea7b..c5c5c2ade0c 100644 --- a/apps/docs/content/docs/en/integrations/notion-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/notion-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Notion Service Accounts +title: Notion Internal Integrations description: Set up a Notion internal integration with an integration secret to use Notion in Sim workflows --- diff --git a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx index 3b325ea8455..38668474317 100644 --- a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Shopify Service Accounts +title: Shopify Admin API Tokens description: Set up a Shopify custom app with an Admin API access token to use Shopify in Sim workflows --- diff --git a/apps/docs/content/docs/en/integrations/trello-service-account.mdx b/apps/docs/content/docs/en/integrations/trello-service-account.mdx index 652b9754eb8..5893e178c45 100644 --- a/apps/docs/content/docs/en/integrations/trello-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/trello-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Trello Service Accounts +title: Trello API Tokens description: Mint a Trello API token bound to Sim's API key to use Trello in Sim workflows without OAuth --- diff --git a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx index 44b8e43fac0..980e2aec5a3 100644 --- a/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/wealthbox-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Wealthbox Service Accounts +title: Wealthbox API Access Tokens description: Set up a Wealthbox API access token to use Wealthbox in Sim workflows without OAuth --- diff --git a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx index 623a4cd2373..44f604cd937 100644 --- a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx @@ -1,5 +1,5 @@ --- -title: Webflow Service Accounts +title: Webflow Site Tokens description: Set up a Webflow site API token with the right scopes to use Webflow CMS in Sim workflows --- diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 6ca5964d8c4..d7ecd79e36e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' import { blockTypeToIconMap, type Integration, @@ -87,6 +88,15 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration }, [isSlackBot, blockOverlayVersion]) const hasServiceAccount = Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden + // Vendor-accurate connect label: token-paste providers use their own noun + // ("Add API key", "Add private app token"); only true service-account + // providers (Google, Atlassian) say "Add service account". + const tokenDescriptor = getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) + const serviceAccountConnectLabel = isSlackBot + ? 'Set up a custom bot' + : tokenDescriptor + ? `Add ${tokenDescriptor.connectNoun}` + : 'Add service account' const hasHandledConnectQueryRef = useRef(false) useEffect(() => { @@ -116,7 +126,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration }, { value: CONNECT_MODE.serviceAccount, - label: isSlackBot ? 'Set up a custom bot' : 'Add service account', + label: serviceAccountConnectLabel, icon: oauthService.serviceIcon, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index 11bc344d335..de4e8943acd 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -23,7 +23,7 @@ import { const logger = createLogger('TokenServiceAccountModal') -const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment." +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 @@ -159,10 +159,10 @@ export function TokenServiceAccountModal({ onOpenChange(false)}> - Add {serviceName} service account + Add {serviceName} {descriptor.connectNoun} {tokenField && ( @@ -235,7 +235,7 @@ export function TokenServiceAccountModal({ }, ]} primaryAction={{ - label: isPending ? 'Adding...' : 'Add service account', + label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`, onClick: handleSubmit, disabled: isDisabled, }} diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index 567d8d6fd66..eac00def403 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -35,6 +35,12 @@ export interface TokenServiceAccountDescriptor { 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 @@ -79,6 +85,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'HubSpot', tokenNoun: 'private app access token', + connectNoun: 'private app token', fields: [ { id: 'apiToken', @@ -97,6 +104,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Airtable', tokenNoun: 'personal access token', + connectNoun: 'personal access token', fields: [ { id: 'apiToken', @@ -115,6 +123,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: NOTION_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Notion', tokenNoun: 'internal integration secret', + connectNoun: 'integration secret', fields: [ { id: 'apiToken', @@ -133,6 +142,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: ASANA_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Asana', tokenNoun: 'access token', + connectNoun: 'access token', fields: [ { id: 'apiToken', @@ -149,6 +159,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: ATTIO_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Attio', tokenNoun: 'API key', + connectNoun: 'API key', fields: [ { id: 'apiToken', @@ -165,6 +176,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Linear', tokenNoun: 'API key', + connectNoun: 'API key', fields: [ { id: 'apiToken', @@ -181,6 +193,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: MONDAY_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'monday.com', tokenNoun: 'API token', + connectNoun: 'API token', fields: [ { id: 'apiToken', @@ -197,6 +210,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Shopify', tokenNoun: 'Admin API access token', + connectNoun: 'admin API token', fields: [ { id: 'apiToken', @@ -223,6 +237,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Webflow', tokenNoun: 'site API token', + connectNoun: 'site token', fields: [ { id: 'apiToken', @@ -239,6 +254,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Trello', tokenNoun: 'API token', + connectNoun: 'API token', fields: [ { id: 'apiToken', @@ -257,6 +273,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: CALCOM_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Cal.com', tokenNoun: 'API key', + connectNoun: 'API key', fields: [ { id: 'apiToken', @@ -274,6 +291,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< providerId: WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Wealthbox', tokenNoun: 'API access token', + connectNoun: 'access token', fields: [ { id: 'apiToken', From afc697c6660d4d7fa437f48c30212041ae5f369f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:22:03 -0700 Subject: [PATCH 06/10] 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 --- .../validators/shopify.test.ts | 17 ++++++++++++ .../validators/shopify.ts | 27 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 67b2efb559a..7c14af6c909 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -139,4 +139,21 @@ describe('validateShopifyServiceAccount', () => { 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, + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index cbe651d5135..5a25332e1bd 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -27,6 +27,11 @@ 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?: { @@ -34,7 +39,20 @@ interface ShopifyShopResponse { myshopifyDomain?: string } } - errors?: unknown + 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. */ @@ -89,6 +107,13 @@ export async function validateShopifyServiceAccount( const payload = await parseProviderJson(res, 'shop_query') const shop = payload.data?.shop + if (hasShopifyAuthError(payload.errors)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 401, { + step: 'shop_query', + domain, + reason: 'auth-shaped GraphQL error in 200 response', + }) + } if (payload.errors || !shop) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'shop_query', From 74c7dd64caac1cc1bb1e67287b2c64294c444d00 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:34:38 -0700 Subject: [PATCH 07/10] fix(credentials): empirically-grounded HubSpot token verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../validators/hubspot.test.ts | 127 +++++++++++++++--- .../validators/hubspot.ts | 65 ++++++++- 2 files changed, 163 insertions(+), 29 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index aef6357800c..1259b11ac0a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -5,6 +5,7 @@ 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' } @@ -18,8 +19,37 @@ function jsonResponse(status: number, body: unknown): Response { } 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() @@ -30,18 +60,15 @@ describe('validateHubspotServiceAccount', () => { vi.unstubAllGlobals() }) - it('returns displayName and metadata on success', async () => { - mockFetch.mockImplementation(async (url: string) => { - if (url === TOKEN_INFO_URL) { - return jsonResponse(200, { - userId: 111, - hubId: 12345, - appId: 222, - scopes: ['tickets'], - }) - } - throw new Error(`unexpected fetch: ${url}`) - }) + 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) @@ -51,14 +78,66 @@ describe('validateHubspotServiceAccount', () => { storedMetadata: { hubId: '12345', appId: '222', userId: '111' }, }) - const [, init] = mockFetch.mock.calls[0] - expect(init.method).toBe('POST') - expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb') - expect(JSON.parse(init.body)).toEqual({ tokenKey: 'pat-na1-aaaa-bbbb' }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expectPrimaryCall() + }) + + it('falls back to account-info on primary 404 and succeeds with portalId', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce(jsonResponse(200, { portalId: 123, uiDomain: 'app.hubspot.com' })) + + const result = await validateHubspotServiceAccount(FIELDS) + + expect(result).toEqual({ + displayName: 'HubSpot portal 123', + auditMetadata: { hubspotHubId: '123' }, + storedMetadata: { hubId: '123' }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expectPrimaryCall() + expectFallbackCall() + }) + + it('throws invalid_credentials when primary 404 and fallback returns 401', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' }) + ) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expectPrimaryCall() + expectFallbackCall() }) - it('throws invalid_credentials on 401', async () => { - mockFetch.mockResolvedValue( + 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' }) ) @@ -67,20 +146,24 @@ describe('validateHubspotServiceAccount', () => { code: 'invalid_credentials', status: 401, }) + + expect(mockFetch).toHaveBeenCalledTimes(1) }) - it('throws provider_unavailable on 503', async () => { - mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' })) + 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 malformed success body', async () => { - mockFetch.mockResolvedValue(jsonResponse(200, { unexpected: true })) + 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', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index cfd597dfd77..2413078bca4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,6 +1,7 @@ import { fetchProvider, parseProviderJson, + readProviderErrorSnippet, TokenServiceAccountValidationError, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' @@ -10,6 +11,7 @@ import type { } 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 @@ -18,16 +20,59 @@ interface HubspotTokenInfo { scopes?: string[] } +interface HubspotAccountInfo { + portalId?: number +} + +/** + * Fallback verification via the Account Information API. Live probing shows + * the documented access-token-info route returns a bare 404 for invalid + * tokens, which is ambiguous (invalid token vs. missing route), while this + * regular API route answers with a proper JSON 401 for bad tokens. A 200 + * proves the token is live; 401 means it was rejected; 403 means the token + * authenticated but the app lacks account-info access — still a live token. + */ +async function verifyViaAccountInfo( + accessToken: string +): Promise { + const res = await fetchProvider( + ACCOUNT_INFO_URL, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + 'account_info' + ) + if (res.status === 403) { + return { + displayName: 'HubSpot private app', + auditMetadata: {}, + storedMetadata: {}, + } + } + await throwForProviderResponse(res, 'account_info') + + const info = await parseProviderJson(res, 'account_info') + const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined + return { + displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app', + auditMetadata: hubId ? { hubspotHubId: hubId } : {}, + storedMetadata: hubId ? { hubId } : {}, + } +} + /** - * Validates a HubSpot private app access token by calling the access-token-info - * endpoint. Both the JSON body (`tokenKey`) and the `Authorization: Bearer` - * header are sent — the header is optional for NA (`pat-na1`) tokens but - * required for EU (`pat-eu1`) tokens, so one code path covers both regions. - * The endpoint requires no scopes, so it validates any private-app token. + * 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 - * endpoint's `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), - * not a portal-specific name, so it is not used. + * `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 @@ -46,6 +91,12 @@ export async function validateHubspotServiceAccount( }, 'access_token_info' ) + if (res.status === 404 || res.status === 400) { + // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s + // malformed ones). Resolve via a regular API route with JSON errors. + await readProviderErrorSnippet(res) + return verifyViaAccountInfo(accessToken) + } await throwForProviderResponse(res, 'access_token_info') const tokenInfo = await parseProviderJson(res, 'access_token_info') From dd00d09c1dcb9d66a01ff4ab8db8f043a549a9ed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:39:16 -0700 Subject: [PATCH 08/10] 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 --- .../token-service-account-modal.tsx | 2 +- .../credentials/service-account-secret.test.ts | 15 +++++++++++++++ .../sim/lib/credentials/service-account-secret.ts | 7 ++++++- .../validators/monday.test.ts | 12 ++++++++++++ .../token-service-accounts/validators/monday.ts | 10 +++++++--- .../validators/shopify.test.ts | 12 ++++++++++++ .../token-service-accounts/validators/shopify.ts | 3 ++- apps/sim/tools/shopify/adjust_inventory.ts | 2 +- apps/sim/tools/shopify/cancel_order.ts | 2 +- apps/sim/tools/shopify/create_customer.ts | 2 +- apps/sim/tools/shopify/create_fulfillment.ts | 2 +- apps/sim/tools/shopify/create_product.ts | 2 +- apps/sim/tools/shopify/delete_customer.ts | 2 +- apps/sim/tools/shopify/delete_product.ts | 2 +- apps/sim/tools/shopify/get_collection.ts | 2 +- apps/sim/tools/shopify/get_customer.ts | 2 +- apps/sim/tools/shopify/get_inventory_level.ts | 2 +- apps/sim/tools/shopify/get_order.ts | 2 +- apps/sim/tools/shopify/get_product.ts | 2 +- apps/sim/tools/shopify/list_collections.ts | 2 +- apps/sim/tools/shopify/list_customers.ts | 2 +- apps/sim/tools/shopify/list_inventory_items.ts | 2 +- apps/sim/tools/shopify/list_locations.ts | 2 +- apps/sim/tools/shopify/list_orders.ts | 2 +- apps/sim/tools/shopify/list_products.ts | 2 +- apps/sim/tools/shopify/update_customer.ts | 2 +- apps/sim/tools/shopify/update_order.ts | 2 +- apps/sim/tools/shopify/update_product.ts | 2 +- 28 files changed, 76 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index de4e8943acd..186025caf52 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -54,7 +54,7 @@ function normalizeDomainInput(raw: string): string { return raw .trim() .replace(/^https?:\/\//i, '') - .replace(/\/+$/, '') + .replace(/[/?#].*$/, '') } function openDocs(url: string): void { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 42b1be22b6c..a67bcbd1e48 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -123,4 +123,19 @@ 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') + }) }) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 39884e2f6f8..fd176837b7d 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -234,5 +234,10 @@ export async function verifyAndBuildServiceAccountSecret( if (isTokenServiceAccountProviderId(providerId)) { return buildTokenServiceAccountSecret(providerId, fields) } - return buildGoogleServiceAccountSecret(fields) + if (!providerId) { + // Legacy Google creates omit providerId entirely (the original flow + // predates multi-provider support). + return buildGoogleServiceAccountSecret(fields) + } + throw new ServiceAccountSecretError(`Unsupported service-account provider: ${providerId}`) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index 088c1859cb6..c2d589f7c41 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -148,4 +148,16 @@ describe('validateMondayServiceAccount', () => { expect(error.code).toBe('provider_unavailable') expect(error.status).toBe(500) }) + + it('accepts a valid token when warnings accompany successful me data', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + data: { me: { id: 77, name: 'Bot User' }, account: { id: 5, name: 'Acme', slug: 'acme' } }, + errors: [{ message: 'Deprecated field usage', extensions: { code: 'DEPRECATED' } }], + }) + ) + const result = await validateMondayServiceAccount({ apiToken: 'token' }) + expect(result.displayName).toBe('Acme') + expect(result.storedMetadata?.userId).toBe('77') + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index b3c82ffd47a..9ff853d71ff 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -86,7 +86,13 @@ export async function validateMondayServiceAccount( const body = await parseProviderJson(res, 'me') - const bodyError = extractBodyError(body) + 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, { @@ -100,8 +106,6 @@ export async function validateMondayServiceAccount( }) } - const me = body.data?.me - const account = body.data?.account if (!me?.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'me', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 7c14af6c909..a2380bd428f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -156,4 +156,16 @@ describe('validateShopifyServiceAccount', () => { status: 401, }) }) + + it('normalizes a pasted admin URL down to the bare store host', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { data: { shop: { name: 'Probe', myshopifyDomain: 'my-store.myshopify.com' } } }) + ) + const result = await validateShopifyServiceAccount({ + apiToken: 'shpat_ok', + domain: 'https://my-store.myshopify.com/admin/settings?x=1', + }) + expect(result.normalizedDomain).toBe('my-store.myshopify.com') + expect(mockFetch.mock.calls[0][0]).toContain('https://my-store.myshopify.com/admin/api') + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 5a25332e1bd..4f71977b37a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -58,8 +58,9 @@ function hasShopifyAuthError(errors: unknown): boolean { /** Strips the protocol and trailing slashes and lowercases the store domain. */ function normalizeShopifyDomain(rawDomain: string): string { return rawDomain + .trim() .replace(/^https?:\/\//i, '') - .replace(/\/+$/, '') + .replace(/[/?#].*$/, '') .toLowerCase() } diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index 83fe3268821..950ca816452 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -48,7 +48,7 @@ export const shopifyAdjustInventoryTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index b86c62b533a..3a7ee6d7af9 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -64,7 +64,7 @@ export const shopifyCancelOrderTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts index ca3e090872e..e56d1f2c48d 100644 --- a/apps/sim/tools/shopify/create_customer.ts +++ b/apps/sim/tools/shopify/create_customer.ts @@ -69,7 +69,7 @@ export const shopifyCreateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts index 211f7f96e43..05435306d17 100644 --- a/apps/sim/tools/shopify/create_fulfillment.ts +++ b/apps/sim/tools/shopify/create_fulfillment.ts @@ -61,7 +61,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index 4a57deba2c4..dd1fb16c8d3 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -63,7 +63,7 @@ export const shopifyCreateProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts index 53e615e2a40..010cf109c83 100644 --- a/apps/sim/tools/shopify/delete_customer.ts +++ b/apps/sim/tools/shopify/delete_customer.ts @@ -32,7 +32,7 @@ export const shopifyDeleteCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts index cca4e35fecc..42b3aa843fa 100644 --- a/apps/sim/tools/shopify/delete_product.ts +++ b/apps/sim/tools/shopify/delete_product.ts @@ -32,7 +32,7 @@ export const shopifyDeleteProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index 29e3f859205..6cf7afa8dd6 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -40,7 +40,7 @@ export const shopifyGetCollectionTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts index 67d0810dfdb..e7657df42c7 100644 --- a/apps/sim/tools/shopify/get_customer.ts +++ b/apps/sim/tools/shopify/get_customer.ts @@ -31,7 +31,7 @@ export const shopifyGetCustomerTool: ToolConfig - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index b5e315cdae7..215bd731a36 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -42,7 +42,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts index 9093b8ea0c3..65f69ddaa02 100644 --- a/apps/sim/tools/shopify/get_order.ts +++ b/apps/sim/tools/shopify/get_order.ts @@ -30,7 +30,7 @@ export const shopifyGetOrderTool: ToolConfig - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts index 6129a85421e..e99c3b1140f 100644 --- a/apps/sim/tools/shopify/get_product.ts +++ b/apps/sim/tools/shopify/get_product.ts @@ -30,7 +30,7 @@ export const shopifyGetProductTool: ToolConfig - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index fe22a3f7856..bc7bd3e2f0e 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -44,7 +44,7 @@ export const shopifyListCollectionsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts index 46f3370d9fb..0ecc1ab31e5 100644 --- a/apps/sim/tools/shopify/list_customers.ts +++ b/apps/sim/tools/shopify/list_customers.ts @@ -40,7 +40,7 @@ export const shopifyListCustomersTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 997cbcdc46d..57de928cd96 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -44,7 +44,7 @@ export const shopifyListInventoryItemsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index 4894f12810f..f51817b23a3 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -40,7 +40,7 @@ export const shopifyListLocationsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts index eb415e8759e..4d818bde393 100644 --- a/apps/sim/tools/shopify/list_orders.ts +++ b/apps/sim/tools/shopify/list_orders.ts @@ -43,7 +43,7 @@ export const shopifyListOrdersTool: ToolConfig - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts index 2acd27562a4..ceb6cae7d23 100644 --- a/apps/sim/tools/shopify/list_products.ts +++ b/apps/sim/tools/shopify/list_products.ts @@ -40,7 +40,7 @@ export const shopifyListProductsTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts index 513ccb906d4..af3ee478cbd 100644 --- a/apps/sim/tools/shopify/update_customer.ts +++ b/apps/sim/tools/shopify/update_customer.ts @@ -69,7 +69,7 @@ export const shopifyUpdateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts index 58d4415ac32..9e34c3476aa 100644 --- a/apps/sim/tools/shopify/update_order.ts +++ b/apps/sim/tools/shopify/update_order.ts @@ -48,7 +48,7 @@ export const shopifyUpdateOrderTool: ToolConfig - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index 729074947b0..771dca61343 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -69,7 +69,7 @@ export const shopifyUpdateProductTool: ToolConfig< request: { url: (params) => - `https://${params.shopDomain || params.domain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { From 5e1ab3960b2f4f80f3b6a92dbaef782bfebad2ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 19:55:26 -0700 Subject: [PATCH 09/10] chore(credentials): format shopify validator test --- .../token-service-accounts/validators/shopify.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index a2380bd428f..63715e4359a 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -159,7 +159,9 @@ describe('validateShopifyServiceAccount', () => { 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' } } }) + jsonResponse(200, { + data: { shop: { name: 'Probe', myshopifyDomain: 'my-store.myshopify.com' } }, + }) ) const result = await validateShopifyServiceAccount({ apiToken: 'shpat_ok', From 2452f9646cc97d78d78c0fb2e5bc0ff3b20775c8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 20:25:31 -0700 Subject: [PATCH 10/10] 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 --- .../en/integrations/airtable-service-account.mdx | 6 +++--- .../en/integrations/asana-service-account.mdx | 6 +++--- .../en/integrations/attio-service-account.mdx | 6 +++--- .../en/integrations/calcom-service-account.mdx | 6 +++--- .../en/integrations/hubspot-service-account.mdx | 10 +++++----- .../en/integrations/linear-service-account.mdx | 6 +++--- .../en/integrations/monday-service-account.mdx | 6 +++--- .../en/integrations/notion-service-account.mdx | 6 +++--- .../en/integrations/shopify-service-account.mdx | 6 +++--- .../en/integrations/trello-service-account.mdx | 10 +++++----- .../en/integrations/wealthbox-service-account.mdx | 6 +++--- .../en/integrations/webflow-service-account.mdx | 6 +++--- apps/sim/app/api/auth/oauth/utils.ts | 5 ++++- apps/sim/app/api/tools/linear/projects/route.ts | 6 +++++- apps/sim/app/api/tools/linear/teams/route.ts | 6 +++++- .../sim/lib/credentials/service-account-fields.ts | 15 ++++++++------- .../credentials/service-account-secret.test.ts | 8 ++++++++ .../sim/lib/credentials/service-account-secret.ts | 4 +++- .../credentials/token-service-accounts/errors.ts | 4 +++- .../validators/airtable.test.ts | 1 + .../validators/asana.test.ts | 1 + .../validators/attio.test.ts | 1 + .../validators/calcom.test.ts | 1 + .../token-service-accounts/validators/hubspot.ts | 6 ++++-- .../validators/monday.test.ts | 1 + .../validators/notion.test.ts | 1 + .../validators/shopify.test.ts | 1 + .../validators/webflow.test.ts | 1 + 28 files changed, 88 insertions(+), 54 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx index 5f3ce7ec686..b944379097d 100644 --- a/apps/docs/content/docs/en/integrations/airtable-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/airtable-service-account.mdx @@ -72,17 +72,17 @@ Enterprise organizations can enable "Block API access to organization-owned base Open your workspace **Settings** and go to the **Integrations** tab - Search for "Airtable Service Account" and click it + Search for "Airtable Service Account" and click it, then click **Add to Sim** and choose **Add personal access token** {/* TODO(screenshot): Integrations page with "Airtable Service Account" in the service list */} Paste the **Personal access token**, and optionally set a display name and description - {/* TODO(screenshot): Add Airtable Service Account dialog with the personal access token filled in */} + {/* TODO(screenshot): Add Airtable personal access token dialog with the personal access token filled in */} - Click **Add Service Account**. Sim verifies the token by calling Airtable's `whoami` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/asana-service-account.mdx b/apps/docs/content/docs/en/integrations/asana-service-account.mdx index aa37cbac920..51501c708ba 100644 --- a/apps/docs/content/docs/en/integrations/asana-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/asana-service-account.mdx @@ -76,17 +76,17 @@ If you're not on an Enterprise plan, a personal access token works identically o Open your workspace **Settings** and go to the **Integrations** tab - Search for "Asana Service Account" and click it + Search for "Asana Service Account" and click it, then click **Add to Sim** and choose **Add access token** {/* TODO(screenshot): Integrations page with "Asana Service Account" in the service list */} Paste the token — service account token or personal access token, both work in the same field — and optionally set a display name and description - {/* TODO(screenshot): Add Asana Service Account dialog with the access token filled in */} + {/* TODO(screenshot): Add Asana access token dialog with the access token filled in */} - Click **Add Service Account**. Sim verifies the token by calling Asana's `/users/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/attio-service-account.mdx b/apps/docs/content/docs/en/integrations/attio-service-account.mdx index 2e6ffc26996..efb048b172d 100644 --- a/apps/docs/content/docs/en/integrations/attio-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/attio-service-account.mdx @@ -64,17 +64,17 @@ The API key is bearer credentials for your Attio workspace. Treat it like a pass Open your workspace **Settings** and go to the **Integrations** tab - Search for "Attio Service Account" and click it + Search for "Attio Service Account" and click it, then click **Add to Sim** and choose **Add API key** {/* TODO(screenshot): Integrations page with "Attio Service Account" in the service list */} Paste the API key and optionally set a display name and description - {/* TODO(screenshot): Add Attio Service Account dialog with the API key filled in */} + {/* TODO(screenshot): Add Attio API key dialog with the API key filled in */} - Click **Add Service Account**. Sim verifies the key by calling Attio's `/v2/self` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx index 37c18462d7b..6204ea11018 100644 --- a/apps/docs/content/docs/en/integrations/calcom-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/calcom-service-account.mdx @@ -48,17 +48,17 @@ The API key carries the full privileges of the user who created it — there is Open your workspace **Settings** and go to the **Integrations** tab - Search for "Cal.com Service Account" and click it + Search for "Cal.com Service Account" and click it, then click **Add to Sim** and choose **Add API key** {/* TODO(screenshot): Integrations page with "Cal.com Service Account" in the service list */} Paste the API key, and optionally set a display name and description - {/* TODO(screenshot): Add Cal.com Service Account dialog with the API key filled in */} + {/* TODO(screenshot): Add Cal.com API key dialog with the API key filled in */} - Click **Add Service Account**. 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. + 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. diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx index 391df95d5cb..2489fc1c750 100644 --- a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -79,7 +79,7 @@ crm.lists.read crm.lists.write ``` -Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab and the change takes effect immediately, no new token needed. +Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab, commit the change in HubSpot, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim. Sim's OAuth flow also requests an `oauth` scope — that one is OAuth-app-only and doesn't exist for private apps. You don't need it and can't grant it. @@ -100,17 +100,17 @@ The access token is bearer credentials for your entire portal, limited only by i Open your workspace **Settings** and go to the **Integrations** tab - Search for "HubSpot Service Account" and click it + Search for "HubSpot Service Account" and click it, then click **Add to Sim** and choose **Add private app token** {/* TODO(screenshot): Integrations page with "HubSpot Service Account" in the service list */} Paste the **Private app access token**, and optionally set a display name and description - {/* TODO(screenshot): Add HubSpot Service Account dialog with the private app access token filled in */} + {/* TODO(screenshot): Add HubSpot private app token dialog with the private app access token filled in */} - Click **Add Service Account**. Sim verifies the token against HubSpot's token-info endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. @@ -134,7 +134,7 @@ Prefer the 7-day option: rotate in HubSpot, paste the new token into the credent - Search for "Linear Service Account" and click it + Search for "Linear Service Account" and click it, then click **Add to Sim** and choose **Add API key** {/* TODO(screenshot): Integrations page with "Linear Service Account" in the service list */} Paste the API key (`lin_api_...`) and optionally set a display name and description - {/* TODO(screenshot): Add Linear Service Account dialog with the API key filled in */} + {/* TODO(screenshot): Add Linear API key dialog with the API key filled in */} - Click **Add Service Account**. Sim verifies the key by querying Linear's `viewer` — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/monday-service-account.mdx b/apps/docs/content/docs/en/integrations/monday-service-account.mdx index 075eeccd917..d65903d43b8 100644 --- a/apps/docs/content/docs/en/integrations/monday-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/monday-service-account.mdx @@ -64,17 +64,17 @@ There is no scope picker: personal API tokens carry **all** API permission scope Open your workspace **Settings** and go to the **Integrations** tab - Search for "monday.com Service Account" and click it + Search for "monday.com Service Account" and click it, then click **Add to Sim** and choose **Add API token** {/* TODO(screenshot): Integrations page with "monday.com Service Account" in the service list */} Paste the API token and optionally set a display name and description - {/* TODO(screenshot): Add monday.com Service Account dialog with the API token filled in */} + {/* TODO(screenshot): Add monday.com API token dialog with the API token filled in */} - Click **Add Service Account**. Sim verifies the token by querying monday.com's `me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/notion-service-account.mdx b/apps/docs/content/docs/en/integrations/notion-service-account.mdx index c5c5c2ade0c..16c742e9212 100644 --- a/apps/docs/content/docs/en/integrations/notion-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/notion-service-account.mdx @@ -81,17 +81,17 @@ A valid secret with no page connections validates fine in Sim but returns `404 o Open your workspace **Settings** and go to the **Integrations** tab - Search for "Notion Service Account" and click it + Search for "Notion Service Account" and click it, then click **Add to Sim** and choose **Add integration secret** {/* TODO(screenshot): Integrations page with "Notion Service Account" in the service list */} Paste the **Internal integration secret**, and optionally set a display name and description - {/* TODO(screenshot): Add Notion Service Account dialog with the internal integration secret filled in */} + {/* TODO(screenshot): Add Notion integration secret dialog with the internal integration secret filled in */} - Click **Add Service Account**. Sim verifies the secret by calling Notion's bot-user endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx index 38668474317..7111029a1fb 100644 --- a/apps/docs/content/docs/en/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/shopify-service-account.mdx @@ -75,17 +75,17 @@ Use the permanent `.myshopify.com` domain — for example, `your-store.myshopify Open your workspace **Settings** and go to the **Integrations** tab - Search for "Shopify Service Account" and click it + Search for "Shopify Service Account" and click it, then click **Add to Sim** and choose **Add admin API token** {/* TODO(screenshot): Integrations page with "Shopify Service Account" in the service list */} Paste the Admin API access token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description - {/* TODO(screenshot): Add Shopify Service Account dialog with token and store domain filled in */} + {/* TODO(screenshot): Add Shopify admin API token dialog with token and store domain filled in */} - Click **Add Service Account**. Sim verifies the token by querying your store's `shop` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/trello-service-account.mdx b/apps/docs/content/docs/en/integrations/trello-service-account.mdx index 5893e178c45..57e3c8f11a0 100644 --- a/apps/docs/content/docs/en/integrations/trello-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/trello-service-account.mdx @@ -28,7 +28,7 @@ https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=t The `key` parameter must be the Trello API key of the Sim deployment: -- **Sim's hosted cloud** — the full authorize link with the key already filled in is provided in-product when you add the credential (or via support). Don't substitute your own key. +- **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. @@ -61,17 +61,17 @@ Newer Trello tokens start with `ATTA`; older ones are 64-character hex strings. Open your workspace **Settings** and go to the **Integrations** tab - Search for "Trello Service Account" and click it + Search for "Trello Service Account" and click it, then click **Add to Sim** and choose **Add API token** {/* TODO(screenshot): Integrations page with "Trello Service Account" in the service list */} Paste the API token, and optionally set a display name and description - {/* TODO(screenshot): Add Trello Service Account dialog with the API token filled in */} + {/* TODO(screenshot): Add Trello API token dialog with the API token filled in */} - Click **Add Service Account**. 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. + 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. @@ -87,7 +87,7 @@ The block calls Trello's API (`api.trello.com`) with the token, paired server-si - Search for "Wealthbox Service Account" and click it + Search for "Wealthbox Service Account" and click it, then click **Add to Sim** and choose **Add access token** {/* TODO(screenshot): Integrations page with "Wealthbox Service Account" in the service list */} Paste the API access token, and optionally set a display name and description - {/* TODO(screenshot): Add Wealthbox Service Account dialog with the API access token filled in */} + {/* TODO(screenshot): Add Wealthbox access token dialog with the API access token filled in */} - Click **Add Service Account**. Sim verifies the token by calling Wealthbox's `/v1/me` endpoint — if it fails, you'll see a specific error explaining what went wrong. + 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. diff --git a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx index 44f604cd937..71441847978 100644 --- a/apps/docs/content/docs/en/integrations/webflow-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/webflow-service-account.mdx @@ -55,17 +55,17 @@ Site tokens expire after **365 consecutive days of inactivity**. Any API call re Open your workspace **Settings** and go to the **Integrations** tab - Search for "Webflow Service Account" and click it + Search for "Webflow Service Account" and click it, then click **Add to Sim** and choose **Add site token** {/* TODO(screenshot): Integrations page with "Webflow Service Account" in the service list */} Paste the site API token and optionally set a display name and description - {/* TODO(screenshot): Add Webflow Service Account dialog with the site API token filled in */} + {/* TODO(screenshot): Add Webflow site token dialog with the site API token filled in */} - Click **Add Service Account**. 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. + 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. diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 092709d9d93..b582fdee99f 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -413,7 +413,10 @@ export async function resolveServiceAccountToken( const secret = await getTokenServiceAccountSecret(credentialId, providerId) return { accessToken: secret.apiToken, domain: secret.domain } } - const resolver = providerId ? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId] : undefined + 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'}`) } diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index 9b0b500e0db..c549654b80a 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -88,7 +88,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const linearClient = new LinearClient({ accessToken }) + const linearClient = accessToken.startsWith('lin_api_') + ? // Personal API keys use the SDK's apiKey option (bare Authorization + // header), matching linearAuthorizationHeader in @/tools/linear/utils. + new LinearClient({ apiKey: accessToken }) + : new LinearClient({ accessToken }) /** * teamId may be a single ID or a comma-separated list when the basic-mode diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index f71adec6b0a..a03ccaea4d6 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -86,7 +86,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const linearClient = new LinearClient({ accessToken }) + const linearClient = accessToken.startsWith('lin_api_') + ? // Personal API keys use the SDK's apiKey option (bare Authorization + // header), matching linearAuthorizationHeader in @/tools/linear/utils. + new LinearClient({ apiKey: accessToken }) + : new LinearClient({ accessToken }) const allTeams = await fetchAllTeams(linearClient) const teams = allTeams.map((team: Team) => ({ id: team.id, diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 992995da41e..ea505ddd9c4 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -14,10 +14,11 @@ export type ServiceAccountFieldId = | 'botToken' /** - * Required create-body fields per service-account provider — the single - * client-safe source of truth consumed by the `createCredentialBodySchema` - * superRefine and by field checks in `verifyAndBuildServiceAccountSecret`. - * Token-paste providers contribute their entries from + * 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. */ @@ -40,7 +41,7 @@ export const FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS: readonly ServiceAccountFi export function getServiceAccountRequiredFields( providerId: string | null | undefined ): readonly ServiceAccountFieldId[] { - return ( - SERVICE_ACCOUNT_REQUIRED_FIELDS[providerId ?? ''] ?? FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS - ) + return providerId && Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, providerId) + ? SERVICE_ACCOUNT_REQUIRED_FIELDS[providerId] + : FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index a67bcbd1e48..ea33f8e2ac2 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -138,4 +138,12 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) ).rejects.toThrow('Unsupported service-account provider') }) + + it('rejects prototype-chain providerIds with a validation error, not a TypeError', async () => { + for (const providerId of ['__proto__', 'constructor', 'toString']) { + await expect( + verifyAndBuildServiceAccountSecret(providerId, { serviceAccountJson: '{}' }) + ).rejects.toThrow('Unsupported service-account provider') + } + }) }) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index fd176837b7d..a14f0d4b5ca 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -229,7 +229,9 @@ export async function verifyAndBuildServiceAccountSecret( providerId: string, fields: ServiceAccountSecretFields ): Promise { - const builder = SERVICE_ACCOUNT_SECRET_BUILDERS[providerId] + 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) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 65ad9dc0532..1c8e1114531 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -29,13 +29,15 @@ const ERROR_SNIPPET_MAX_LENGTH = 500 * as raw undici errors — whose `cause` can carry connection details — and are * never blamed on the pasted token. */ +const PROVIDER_FETCH_TIMEOUT_MS = 10_000 + export async function fetchProvider( url: string, init: RequestInit, step: string ): Promise { try { - return await fetch(url, init) + return await fetch(url, { ...init, signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) }) } catch { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts index a81af1d0f89..b1f4797b509 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts @@ -45,6 +45,7 @@ describe('validateAirtableServiceAccount', () => { Authorization: 'Bearer pat123.secret', Accept: 'application/json', }, + signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts index f943bd794b6..6e950a55002 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts @@ -45,6 +45,7 @@ describe('validateAsanaServiceAccount', () => { Authorization: 'Bearer token-1', Accept: 'application/json', }, + signal: expect.any(AbortSignal), } ) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index ff61013fd2b..7c193f063e5 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -56,6 +56,7 @@ describe('validateAttioServiceAccount', () => { Authorization: 'Bearer attio-token', Accept: 'application/json', }, + signal: expect.any(AbortSignal), }) expect(result).toEqual({ displayName: 'Acme CRM', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts index 74e116956be..cbd22a5b1a2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts @@ -44,6 +44,7 @@ describe('validateCalcomServiceAccount', () => { Authorization: 'Bearer cal_live_token', Accept: 'application/json', }, + signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 2413078bca4..457710e32c8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -91,9 +91,11 @@ export async function validateHubspotServiceAccount( }, 'access_token_info' ) - if (res.status === 404 || res.status === 400) { + if (res.status === 404 || res.status === 400 || res.status === 403) { // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s - // malformed ones). Resolve via a regular API route with JSON errors. + // 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) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index c2d589f7c41..e96e7006b72 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -51,6 +51,7 @@ describe('validateMondayServiceAccount', () => { body: JSON.stringify({ query: 'query { me { id name email } account { id name slug } }', }), + signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts index 6d57b5ccaa6..fad5f227b30 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -48,6 +48,7 @@ describe('validateNotionServiceAccount', () => { 'Notion-Version': '2022-06-28', Accept: 'application/json', }, + signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 63715e4359a..f8784e9bb5d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -56,6 +56,7 @@ describe('validateShopifyServiceAccount', () => { 'X-Shopify-Access-Token': 'shpat_abc', }, body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }), + signal: expect.any(AbortSignal), } ) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts index f95d9cc1253..1cb3f9018c8 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -43,6 +43,7 @@ describe('validateWebflowServiceAccount', () => { Authorization: 'Bearer wf-token', Accept: 'application/json', }, + signal: expect.any(AbortSignal), }) })