Skip to content

Commit 1da346b

Browse files
feat(triggers): service-account credentials in the hubspot trigger + token-SA name-collision 409 (#5693)
1 parent 5944c7c commit 1da346b

6 files changed

Lines changed: 33 additions & 3 deletions

File tree

apps/docs/content/docs/en/integrations/hubspot-service-account.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ The access token is bearer credentials for your entire portal, limited only by i
116116

117117
## Using the Service Account in Workflows
118118

119-
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.
119+
Add a HubSpot block or the HubSpot CRM Trigger 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. For the polling trigger, a service account is often the better choice — the token never expires and keeps working even if the person who connected it leaves the portal.
120120

121121
{/* TODO(screenshot): HubSpot block in a workflow with the HubSpot service account selected as the credential */}
122122

apps/sim/app/api/credentials/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ServiceAccountSecretError,
2828
verifyAndBuildServiceAccountSecret,
2929
} from '@/lib/credentials/service-account-secret'
30+
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
3031
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
3132
import { getServiceConfigByProviderId } from '@/lib/oauth'
3233
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
@@ -443,6 +444,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
443444
)
444445
}
445446

447+
// Token service-account creates always carry a fresh token that must be
448+
// stored — falling through to the existing-credential path would return
449+
// the old credential as success and silently drop the submitted token.
450+
if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) {
451+
return NextResponse.json(
452+
{
453+
code: 'duplicate_display_name',
454+
error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`,
455+
},
456+
{ status: 409 }
457+
)
458+
}
459+
446460
const access = await getCredentialActorContext(existingCredential.id, session.user.id, {
447461
workspaceAccess,
448462
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ export function CredentialSelector({
106106
if (credentialKind === 'custom-bot') {
107107
return rawCredentials.filter((cred) => cred.type === 'service_account')
108108
}
109-
return isTriggerMode
109+
return isTriggerMode && !subBlock.allowServiceAccounts
110110
? rawCredentials.filter((cred) => cred.type !== 'service_account')
111111
: rawCredentials
112-
}, [rawCredentials, isTriggerMode, credentialKind])
112+
}, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts])
113113

114114
const selectedCredential = useMemo(
115115
() => credentials.find((cred) => cred.id === selectedId),

apps/sim/blocks/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,13 @@ export interface SubBlockConfig {
372372
* connect row opens the custom-bot setup modal instead of the OAuth flow.
373373
*/
374374
credentialKind?: 'custom-bot'
375+
/**
376+
* Opts a trigger-mode `oauth-input` selector into listing service-account
377+
* credentials, which are otherwise excluded in trigger mode. Set only when the
378+
* trigger's server-side polling path can resolve the provider's service-account
379+
* token (see `resolveOAuthCredential` in `@/lib/webhooks/polling/utils`).
380+
*/
381+
allowServiceAccounts?: boolean
375382
// Selector properties — declarative mapping to a SelectorKey
376383
selectorKey?: SelectorKey
377384
selectorAllowSearch?: boolean

apps/sim/lib/webhooks/polling/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getOAuthToken,
99
refreshAccessTokenIfNeeded,
1010
resolveOAuthAccountId,
11+
resolveServiceAccountToken,
1112
} from '@/app/api/auth/oauth/utils'
1213
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
1314

@@ -203,6 +204,13 @@ export async function resolveOAuthCredential(
203204
`Failed to resolve OAuth account for credential ${credentialId}, webhook ${webhookData.id}`
204205
)
205206
}
207+
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
208+
const { accessToken: serviceAccountToken } = await resolveServiceAccountToken(
209+
resolved.credentialId,
210+
resolved.providerId
211+
)
212+
return serviceAccountToken
213+
}
206214
const rows = await db.select().from(account).where(eq(account.id, resolved.accountId)).limit(1)
207215
if (!rows.length) {
208216
throw new Error(`Credential ${credentialId} not found for webhook ${webhookData.id}`)

apps/sim/triggers/hubspot/poller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const hubspotPollingTrigger: TriggerConfig = {
6161
description: 'Connect a HubSpot account so Sim can poll your CRM on your behalf.',
6262
serviceId: 'hubspot',
6363
requiredScopes: getScopesForService('hubspot'),
64+
allowServiceAccounts: true,
6465
required: true,
6566
mode: 'trigger',
6667
},

0 commit comments

Comments
 (0)