Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
689 changes: 689 additions & 0 deletions apps/docs/content/docs/integrations/planetscale.mdx

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions apps/sim/app/api/webhooks/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
telemetryMock,
workflowAuthzMockFns,
} from '@sim/testing'
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
import { DrizzleQueryError } from 'drizzle-orm/errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -78,6 +80,65 @@ describe('POST /api/webhooks polling configuration', () => {
mocks.getProviderHandler.mockReturnValue({ configurePolling: mocks.configurePolling })
})

it.each([false, true])(
'keeps saved credentials and database failure parameters out of logs (failure: %s)',
async (failure) => {
const credential = 'fixture-credential-must-never-be-logged'
const config = { password: credential }
const saved = {
id: 'webhook-1',
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'safe-path',
provider: 'generic',
providerConfig: config,
isActive: true,
}
queueTableRows(workflow, [
{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'canonical-workspace' },
])
queueTableRows(webhook, [saved])
mocks.createExternalWebhookSubscription.mockResolvedValueOnce({
updatedProviderConfig: config,
externalSubscriptionCreated: false,
})
if (failure) {
dbChainMockFns.returning.mockRejectedValueOnce(
new DrizzleQueryError(
'update webhook set provider_config = $1',
[JSON.stringify(config)],
new Error(credential)
)
)
} else {
dbChainMockFns.returning.mockResolvedValueOnce([saved])
}
const response = await POST(
createMockRequest(
'POST',
{
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'safe-path',
provider: 'generic',
providerConfig: config,
},
{},
'http://localhost:3000/api/webhooks'
)
)
expect(response.status).toBe(failure ? 500 : 200)
const logger = getMockLogger('WebhooksAPI')
const logged = [
logger.info.mock.calls,
logger.warn.mock.calls,
logger.error.mock.calls,
].flat()
expect(JSON.stringify(logged)).not.toContain(credential)
if (failure) expect(await response.text()).not.toContain(credential)
}
)

it('creates an active IMAP webhook instead of rebinding a historical row', async () => {
const savedWebhook = {
id: 'webhook-active',
Expand Down Expand Up @@ -144,6 +205,55 @@ describe('POST /api/webhooks polling configuration', () => {
})
})

it('keeps credential-bearing rollback failures out of polling logs and error responses', async () => {
const credential = 'fixture-rollback-secret-must-never-escape'
const saved = {
id: 'webhook-1',
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'rollback-path',
provider: 'imap',
providerConfig: { password: credential },
isActive: true,
}
queueTableRows(workflow, [
{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'canonical-workspace' },
])
queueTableRows(webhook, [{ id: saved.id }])
queueTableRows(webhook, [saved])
dbChainMockFns.returning.mockResolvedValueOnce([saved])
mocks.configurePolling.mockImplementationOnce(async () => {
dbChainMockFns.where.mockRejectedValueOnce(
new DrizzleQueryError(
'update webhook set provider_config = $1',
[JSON.stringify(saved.providerConfig)],
new Error(credential)
)
)
return false
})
const response = await POST(
createMockRequest(
'POST',
{
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'rollback-path',
provider: 'imap',
providerConfig: saved.providerConfig,
},
{},
'http://localhost:3000/api/webhooks'
)
)
expect(response.status).toBe(500)
expect(await response.text()).not.toContain(credential)
const logger = getMockLogger('WebhooksAPI')
expect(
JSON.stringify([logger.info.mock.calls, logger.warn.mock.calls, logger.error.mock.calls])
).not.toContain(credential)
})

it('restores the previous IMAP deployment binding when polling setup fails', async () => {
const existingWebhook = {
id: 'webhook-1',
Expand Down
26 changes: 11 additions & 15 deletions apps/sim/app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ async function revertSavedWebhook(
updatedAt: existingWebhook.updatedAt,
})
.where(eq(webhook.id, savedWebhook.id))
.catch(() => {
throw new Error('Failed to restore previous webhook configuration.')
})
logger.info(`[${requestId}] Restored previous webhook configuration after failed re-save`, {
webhookId: savedWebhook.id,
})
Expand Down Expand Up @@ -515,7 +518,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
resolvedProviderConfig = updatedConfig
externalSubscriptionCreated = result.externalSubscriptionCreated
} catch (err) {
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
logger.error(`[${requestId}] Error creating external webhook subscription`)
return NextResponse.json(
{
error: 'Failed to create external webhook subscription',
Expand Down Expand Up @@ -558,7 +561,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
savedWebhook = updatedResult[0]
logger.info(`[${requestId}] Webhook updated successfully`, {
webhookId: savedWebhook.id,
savedProviderConfig: savedWebhook.providerConfig,
})
} else {
// Create a new webhook
Expand All @@ -585,17 +587,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
} catch (dbError) {
if (externalSubscriptionCreated) {
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`)
try {
await cleanupExternalWebhook(
createTempWebhookData(configToSave),
workflowRecord,
requestId
)
} catch (cleanupError) {
} catch {
logger.error(
`[${requestId}] Failed to cleanup external subscription after DB save failure`,
cleanupError
`[${requestId}] Failed to cleanup external subscription after DB save failure`
)
}
}
Expand All @@ -605,10 +606,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (existingWebhook && shouldRecreateSubscription) {
try {
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId)
} catch (cleanupError) {
} catch {
logger.warn(
`[${requestId}] Failed to cleanup previous external webhook subscription ${existingWebhook.id}`,
cleanupError
`[${requestId}] Failed to cleanup previous external webhook subscription ${existingWebhook.id}`
)
}
}
Expand Down Expand Up @@ -646,8 +646,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
logger.info(`[${requestId}] Successfully configured ${provider} polling`)
} catch (err) {
logger.error(
`[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`,
err
`[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`
)
await revertSavedWebhook(savedWebhook, existingWebhook, requestId)
return NextResponse.json(
Expand Down Expand Up @@ -713,10 +712,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: error.message }, { status: error.status })
}

logger.error(`[${requestId}] Error creating/updating webhook`, {
message: error.message,
stack: error.stack,
})
logger.error(`[${requestId}] Error creating/updating webhook`)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
52 changes: 50 additions & 2 deletions apps/sim/blocks/blocks/planetscale.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PlanetScaleIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
import { getTrigger } from '@/triggers'

function optionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined
Expand Down Expand Up @@ -28,10 +29,18 @@ export const PlanetScaleBlock: BlockConfig = {
byOperation: {
list_databases: ['List databases', { text: 'in', field: 'organization', core: true }],
get_database: [
{ text: 'Read database', field: ['databaseSelector', 'manualDatabase'], core: true },
{
text: 'Read database',
field: ['databaseSelector', 'manualDatabase'],
core: true,
},
],
list_branches: [
{ text: 'List branches in', field: ['databaseSelector', 'manualDatabase'], core: true },
{
text: 'List branches in',
field: ['databaseSelector', 'manualDatabase'],
core: true,
},
],
get_branch: [{ text: 'Get branch', field: ['branchSelector', 'manualBranch'], core: true }],
create_branch: [
Expand Down Expand Up @@ -789,7 +798,46 @@ export const PlanetScaleBlock: BlockConfig = {
condition: { field: 'operation', value: ['review_deploy_request'] },
required: false,
},
...getTrigger('planetscale_branch_ready').subBlocks,
...getTrigger('planetscale_branch_anomaly').subBlocks,
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
...getTrigger('planetscale_branch_out_of_memory').subBlocks,
...getTrigger('planetscale_branch_primary_promoted').subBlocks,
...getTrigger('planetscale_branch_sleeping').subBlocks,
...getTrigger('planetscale_branch_start_maintenance').subBlocks,
...getTrigger('planetscale_backup_failed').subBlocks,
...getTrigger('planetscale_backup_succeeded').subBlocks,
...getTrigger('planetscale_deploy_request_opened').subBlocks,
...getTrigger('planetscale_deploy_request_queued').subBlocks,
...getTrigger('planetscale_deploy_request_in_progress').subBlocks,
...getTrigger('planetscale_deploy_request_pending_cutover').subBlocks,
...getTrigger('planetscale_deploy_request_schema_applied').subBlocks,
...getTrigger('planetscale_deploy_request_errored').subBlocks,
...getTrigger('planetscale_deploy_request_reverted').subBlocks,
...getTrigger('planetscale_deploy_request_closed').subBlocks,
...getTrigger('planetscale_webhook').subBlocks,
],
triggers: {
enabled: true,
available: [
'planetscale_branch_ready',
'planetscale_branch_anomaly',
'planetscale_branch_out_of_memory',
'planetscale_branch_primary_promoted',
'planetscale_branch_sleeping',
'planetscale_branch_start_maintenance',
'planetscale_backup_failed',
'planetscale_backup_succeeded',
'planetscale_deploy_request_opened',
'planetscale_deploy_request_queued',
'planetscale_deploy_request_in_progress',
'planetscale_deploy_request_pending_cutover',
'planetscale_deploy_request_schema_applied',
'planetscale_deploy_request_errored',
'planetscale_deploy_request_reverted',
'planetscale_deploy_request_closed',
'planetscale_webhook',
],
},
inputs: {
serviceTokenId: { type: 'string', description: 'Service token ID' },
serviceToken: { type: 'string', description: 'Service token secret' },
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/core/security/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ describe('isSensitiveKey', () => {
'api-key',
'Api_Key',
'access_token',
'serviceTokenId',
'triggerServiceTokenId',
'SERVICE_TOKEN_ID',
'service-token-id',
'refreshToken',
'client_secret',
'private_key',
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/security/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const SENSITIVE_KEY_PATTERNS: RegExp[] = [
/^client[_-]?secret$/i,
/^private[_-]?key$/i,
/^auth[_-]?token$/i,
/^.*service[_-]?token[_-]?id$/i,
/^.*secret$/i,
/^.*password$/i,
/^.*token$/i,
Expand Down
46 changes: 46 additions & 0 deletions apps/sim/lib/selectors/server/providers/planetscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,46 @@ describe('PlanetScale server selectors', () => {
fetchMock.mockReset()
})

it('projects trigger credentials and preserves environment references while ignoring stale action values', async () => {
fetchMock.mockResolvedValueOnce(page([{ id: 'fixture-database-id', name: 'fixture-db' }]))
const values = {
selectedTriggerId: 'planetscale_branch_ready',
triggerServiceTokenId: 'fixture-id',
triggerServiceToken: 'fixture-token',
triggerOrganization: 'fixture-org',
triggerDatabaseSelector: 'fixture-db',
triggerManualDatabase: 'stale-manual',
serviceTokenId: 'stale-action-id',
serviceToken: 'stale-action-token',
organization: 'stale-action-org',
}
const picker = PlanetScaleBlock.subBlocks.find(
(field) => field.id === 'triggerDatabaseSelector'
)!
expect(picker).toBeTruthy()
const buildContext = (current: Record<string, unknown>) =>
buildSelectorContextFromValues({
selectorKey: 'planetscale.databases',
contextConfigs: getSelectorContextSubBlocks(PlanetScaleBlock.subBlocks, current, true),
values: current,
dependsOn: getDependsOnFields(picker.dependsOn),
canonicalIndex: buildCanonicalIndexForSurface(PlanetScaleBlock.subBlocks, true),
})
const context = buildContext(values)
await expect(
planetScaleSelectorAttachments['planetscale.databases'].execute(args({ context }))
).resolves.toEqual({ kind: 'list', items: [{ id: 'fixture-db', label: 'fixture-db' }] })
expect(new URL(String(fetchMock.mock.calls[0]?.[0])).pathname).toBe(
'/v1/organizations/fixture-org/databases'
)
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get('Authorization')).toBe(
'fixture-id:fixture-token'
)
expect(
buildContext({ ...values, triggerServiceToken: '{{PLANETSCALE_TOKEN}}' }).serviceToken
).toBe('{{PLANETSCALE_TOKEN}}')
})

it.each([{ serviceToken: '' }, { serviceTokenId: 'id\r\nInjected' }, { organization: '..' }])(
'rejects invalid credential or scope input before contacting the provider: %j',
async (context) => {
Expand Down Expand Up @@ -128,6 +168,9 @@ describe('PlanetScale server selectors', () => {
serviceTokenId: 'test-id',
serviceToken: 'test-secret',
organization: 'example',
triggerServiceTokenId: 'stale-trigger-id',
triggerServiceToken: 'stale-trigger-secret',
triggerOrganization: 'stale-trigger-org',
databaseSelector: 'test-db',
operation,
[branchField]: 'development',
Expand Down Expand Up @@ -164,6 +207,9 @@ describe('PlanetScale server selectors', () => {
serviceTokenId: 'test-id',
serviceToken: 'test-secret',
organization: 'example',
triggerServiceTokenId: 'stale-trigger-id',
triggerServiceToken: 'stale-trigger-secret',
triggerOrganization: 'stale-trigger-org',
databaseSelector: 'test-db',
operation: 'create_branch',
branchSelector: 'stale-hidden',
Expand Down
Loading
Loading