fix(webhooks): resolve env var references before deploy-triggered subscription creation (#5619)

* fix(webhooks): resolve env var references before deploy-triggered subscription creation

Provider config fields like an API key can reference an environment
variable via {{VAR_NAME}}. The interactive trigger-save route already
resolved these before calling a provider's createSubscription, but the
async deployment-outbox path (workflow deploy -> saveTriggerWebhooksForDeploy
-> createExternalWebhookSubscription) did not, so the literal unresolved
{{VAR_NAME}} string was sent to the provider as the credential and
rejected. Resolve env vars in createExternalWebhookSubscription itself so
both callers behave the same; the persisted providerConfig keeps storing
the unresolved template, only the outbound call gets the resolved value.

* fix(webhooks): guard against a non-string workspaceId when resolving env vars

workflow.workspaceId as string | undefined was an unchecked cast on a
Record<string, unknown> — if a caller ever passed a workflow-like object
where workspaceId isn't actually a string, workspace-scoped {{VAR}}
references would silently stay unresolved and the provider would receive
the literal template as the credential, reproducing the exact class of bug
this change exists to fix. Replaced with a runtime typeof check that falls
back to undefined (personal-env-only resolution) instead of forwarding an
unvalidated value.
This commit is contained in:
Waleed
2026-07-11 21:36:53 -07:00
committed by GitHub
parent 1fe94d3c7a
commit f9b09ecfd4
2 changed files with 139 additions and 1 deletions
@@ -0,0 +1,121 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetEffectiveDecryptedEnv, mockGetProviderHandler } = vi.hoisted(() => ({
mockGetEffectiveDecryptedEnv: vi.fn(),
mockGetProviderHandler: vi.fn(),
}))
vi.mock('@/lib/environment/utils', () => ({
getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv,
}))
vi.mock('@/lib/webhooks/providers', () => ({
getProviderHandler: mockGetProviderHandler,
}))
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
describe('createExternalWebhookSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetEffectiveDecryptedEnv.mockResolvedValue({ ASHBY_API_KEY: 'real-secret-key' })
})
it('resolves {{ENV_VAR}} references in providerConfig before calling the provider', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })
const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }
await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'ws-1')
const passedWebhook = createSubscription.mock.calls[0][0].webhook
expect(passedWebhook.providerConfig.apiKey).toBe('real-secret-key')
})
it('persists the unresolved providerConfig, not the resolved one, back to the caller', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })
const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }
const result = await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)
expect(result.updatedProviderConfig.apiKey).toBe('{{ASHBY_API_KEY}}')
expect(result.updatedProviderConfig.externalId).toBe('ext-1')
})
it('falls back to personal-only env resolution when workspaceId is not a string', async () => {
const createSubscription = vi.fn().mockResolvedValue({
providerConfigUpdates: { externalId: 'ext-1' },
})
mockGetProviderHandler.mockReturnValue({ createSubscription })
const webhookData = {
provider: 'ashby',
providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' },
}
const workflow = { id: 'wf-1', workspaceId: null }
await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', undefined)
})
it('skips resolution and provider call entirely when the provider has no createSubscription', async () => {
mockGetProviderHandler.mockReturnValue({})
const webhookData = {
provider: 'slack',
providerConfig: { token: '{{SLACK_TOKEN}}' },
}
const workflow = { id: 'wf-1', workspaceId: 'ws-1' }
const result = await createExternalWebhookSubscription(
{} as NextRequest,
webhookData,
workflow,
'user-1',
'req-1'
)
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
expect(result.externalSubscriptionCreated).toBe(false)
expect(result.updatedProviderConfig.token).toBe('{{SLACK_TOKEN}}')
})
})
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'
const logger = createLogger('WebhookProviderSubscriptions')
@@ -88,6 +89,14 @@ export function shouldRecreateExternalWebhookSubscription({
* Ask the provider handler to create an external webhook subscription, if that
* provider supports automatic registration.
*
* `providerConfig` may contain unresolved `{{ENV_VAR}}` references (e.g. an
* API key field backed by an environment variable) — these are resolved here
* before the provider call so deploy-triggered registration (this function is
* also called from the async deployment outbox, not just the interactive
* webhook-save route) behaves the same as a manual save. The persisted
* `providerConfig` returned to the caller stays unresolved; only the
* provider-managed fields from `result.providerConfigUpdates` get merged in.
*
* The returned provider-managed fields are merged back into `providerConfig`
* by the caller.
*/
@@ -106,8 +115,16 @@ export async function createExternalWebhookSubscription(
return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false }
}
const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
const resolvedProviderConfig = await resolveWebhookProviderConfig(
providerConfig,
userId,
workspaceId
)
const result = await handler.createSubscription({
webhook: webhookData,
webhook: { ...webhookData, providerConfig: resolvedProviderConfig },
workflow,
userId,
requestId,