diff --git a/apps/docs/content/docs/integrations/planetscale.mdx b/apps/docs/content/docs/integrations/planetscale.mdx index 0b172b85c06..8222c271960 100644 --- a/apps/docs/content/docs/integrations/planetscale.mdx +++ b/apps/docs/content/docs/integrations/planetscale.mdx @@ -552,4 +552,693 @@ Close a deploy request (Vitess only) | ↳ `deployedAt` | string | When the deploy request was deployed | | ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request | +{/* MANUAL-CONTENT-START:notes */} +## Webhook delivery and lifecycle limits + +Selected Events uses a nonempty array of production events. A fresh block defaults to `['branch.ready']`; select one or more events before deploying. + +PlanetScale registers webhooks when the workflow is deployed. Use an organization service token with `read_database` and `write_database` permissions for the database, and a publicly reachable HTTPS callback. Branch and backup events support the engines named in each event label; deploy-request events require Vitess. The Selected Events trigger also supports Postgres cluster storage, Vitess keyspace storage, and database access requests. + +PlanetScale permits five webhooks per database. Updating a deployed trigger creates the replacement before deleting the previous subscription, so the database temporarily needs one additional slot. Undeploy removes the subscription; failed cleanup remains retryable. + +Sim authenticates the original request body with the generated signing secret and fingerprints the signed payload, including its native event timestamp. Identical deliveries are deduplicated, while later events with otherwise identical resource snapshots remain distinct. PlanetScale documents neither an immutable delivery ID nor creation idempotency: retries whose payload changes can execute again, and creation failures without a returned ID cannot be recovered exactly. If a returned ID cannot be cleaned up after secret processing fails, inspect the database's webhooks and remove the unused subscription before retrying. + +Live PlanetScale testing is deferred. Before production use, verify token permissions, HTTPS reachability, event delivery for the intended engine, provider retries, subscription updates near the five-webhook limit, and undeploy cleanup using a disposable database. +{/* MANUAL-CONTENT-END */} + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### PlanetScale Backup Failed + +Run on PlanetScale backup failed events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `size` | number | Backup size in bytes, or null | +| ↳ `protected` | boolean | Whether the backup is protected | +| ↳ `startedAt` | string | Start time, or null | +| ↳ `completedAt` | string | Completion time, or null | +| ↳ `expiresAt` | string | Expiry time, or null | +| ↳ `branch` | object | branch output from the tool | +| ↳ `id` | string | Database branch ID, or null | +| ↳ `name` | string | Database branch name, or null | + + +--- + +### PlanetScale Backup Succeeded + +Run on PlanetScale backup succeeded events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `size` | number | Backup size in bytes, or null | +| ↳ `protected` | boolean | Whether the backup is protected | +| ↳ `startedAt` | string | Start time, or null | +| ↳ `completedAt` | string | Completion time, or null | +| ↳ `expiresAt` | string | Expiry time, or null | +| ↳ `branch` | object | branch output from the tool | +| ↳ `id` | string | Database branch ID, or null | +| ↳ `name` | string | Database branch name, or null | + + +--- + +### PlanetScale Branch Anomaly + +Run on PlanetScale branch anomaly events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Branch Out of Memory + +Run on PlanetScale branch out of memory events. Supports Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Branch Primary Promoted + +Run on PlanetScale branch primary promoted events. Supports Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Branch Ready + +Run on PlanetScale branch ready events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Branch Sleeping + +Run on PlanetScale branch sleeping events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Branch Start Maintenance + +Run on PlanetScale branch start maintenance events. Supports Vitess, Neki, and Postgres. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `name` | string | Resource name | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `ready` | boolean | Whether the branch is ready | +| ↳ `production` | boolean | Whether this is a production branch | +| ↳ `safeMigrations` | boolean | Whether safe migrations are enabled | +| ↳ `parentBranch` | string | Parent branch name, or null | +| ↳ `htmlUrl` | string | PlanetScale branch URL, or null | + + +--- + +### PlanetScale Deploy Request Closed + +Run on PlanetScale deploy request closed events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Errored + +Run on PlanetScale deploy request errored events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request In Progress + +Run on PlanetScale deploy request in progress events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Opened + +Run on PlanetScale deploy request opened events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Pending Cutover + +Run on PlanetScale deploy request pending cutover events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Queued + +Run on PlanetScale deploy request queued events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Reverted + +Run on PlanetScale deploy request reverted events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Deploy Request Schema Applied + +Run on PlanetScale deploy request schema applied events. Supports Vitess. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | object | resource output from the tool | +| ↳ `id` | string | Resource ID | +| ↳ `state` | string | Current resource state | +| ↳ `createdAt` | string | Creation time, or null when unavailable | +| ↳ `updatedAt` | string | Update time, or null when unavailable | +| ↳ `number` | number | Database-scoped deploy request number | +| ↳ `branch` | string | Source branch name | +| ↳ `intoBranch` | string | Target branch name | +| ↳ `deploymentState` | string | Current deployment state | +| ↳ `approved` | boolean | Whether the request is approved | +| ↳ `numComments` | number | Number of comments | +| ↳ `notes` | string | Deploy request notes | +| ↳ `closedAt` | string | Close time, or null | +| ↳ `deployedAt` | string | Deployment time, or null | +| ↳ `htmlUrl` | string | PlanetScale deploy request URL | + + +--- + +### PlanetScale Selected Events + +Run on PlanetScale selected events. Supports the documented event-specific database engines. + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerServiceTokenId` | string | Yes | Service Token ID | +| `triggerServiceToken` | string | Yes | Service Token | +| `triggerOrganization` | string | Yes | Organization | +| `triggerDatabaseSelector` | file-selector | Yes | Database | +| `triggerManualDatabase` | string | Yes | Database Name | +| `events` | string | Yes | Events | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Documented PlanetScale event name | +| `timestamp` | number | Provider event time in Unix seconds | +| `organization` | string | Organization slug | +| `database` | string | Database name | +| `payload` | json | Complete original signed webhook payload | +| `resource` | json | Original event-specific resource | diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 64773264d92..245e4010e2c 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -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(() => ({ @@ -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', @@ -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', diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index eb3942c7262..3fce7863929 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -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, }) @@ -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', @@ -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 @@ -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` ) } } @@ -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}` ) } } @@ -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( @@ -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 }) } }) diff --git a/apps/sim/blocks/blocks/planetscale.ts b/apps/sim/blocks/blocks/planetscale.ts index 0107dd5f813..b174cc30734 100644 --- a/apps/sim/blocks/blocks/planetscale.ts +++ b/apps/sim/blocks/blocks/planetscale.ts @@ -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 @@ -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: [ @@ -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, + ...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' }, diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index 4fb12a66060..ad8eb8d12ce 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -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', diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 94f5ccf6866..ec33cd777ac 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -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, diff --git a/apps/sim/lib/selectors/server/providers/planetscale.test.ts b/apps/sim/lib/selectors/server/providers/planetscale.test.ts index 74c1abe6f9c..a4c0368ad93 100644 --- a/apps/sim/lib/selectors/server/providers/planetscale.test.ts +++ b/apps/sim/lib/selectors/server/providers/planetscale.test.ts @@ -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) => + 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) => { @@ -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', @@ -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', diff --git a/apps/sim/lib/webhooks/__integration__/planetscale.integration.ts b/apps/sim/lib/webhooks/__integration__/planetscale.integration.ts new file mode 100644 index 00000000000..fec792aafb4 --- /dev/null +++ b/apps/sim/lib/webhooks/__integration__/planetscale.integration.ts @@ -0,0 +1,276 @@ +import { resolve } from 'node:path' +import { db } from '@sim/db' +import { webhook, workflowExecutionLogs } from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { toRecord } from '@sim/utils/object' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { decryptSecret } from '@/lib/core/security/encryption' +import { createPlanetScaleAppFixture } from '@/testing/planetscale-app-fixture' + +let fixture: Awaited> +const evidence: Record = {} +const payload = { + event: 'branch.ready', + timestamp: 1698252879, + organization: 'fixture-org', + database: 'fixture-db', + resource: { + id: 'fixture-branch-id', + name: 'development', + state: 'ready', + ready: true, + production: false, + safe_migrations: false, + parent_branch: 'main', + created_at: '2023-10-25T16:54:12.879Z', + updated_at: '2023-10-25T16:54:39.820Z', + html_url: 'https://app.planetscale.com/fixture-org/fixture-db/development', + }, +} + +function state(triggerId = 'planetscale_branch_ready', events = ['branch.ready']) { + const values: Record = { + selectedTriggerId: triggerId, + triggerServiceTokenId: fixture.serviceTokenId, + triggerServiceToken: fixture.serviceToken, + triggerOrganization: 'fixture-org', + triggerDatabaseSelector: 'fixture-db', + events, + } + return { + blocks: { + trigger: { + id: 'trigger', + type: 'planetscale', + name: 'PlanetScale', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [ + id, + { + id, + type: + id === 'selectedTriggerId' || id === 'events' + ? 'dropdown' + : id === 'triggerDatabaseSelector' + ? 'file-selector' + : 'short-input', + value, + }, + ]) + ), + outputs: {}, + enabled: true, + triggerMode: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} +async function activeHook() { + const [row] = await db + .select() + .from(webhook) + .where( + and(eq(webhook.workflowId, fixture.workflowId), eq(webhook.registrationStatus, 'active')) + ) + expect(row).toBeTruthy() + return row +} +async function runs() { + return db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.workflowId, fixture.workflowId)) +} +async function storedOutput(run: { id: string }) { + const response = await fixture.request(`/api/logs/${run.id}?workspaceId=${fixture.workspaceId}`) + expect(response.status, await response.clone().text()).toBe(200) + const result = await response.json() + const data = toRecord(result.data.executionData) + expect(JSON.stringify(data)).not.toContain(fixture.serviceToken) + expect(JSON.stringify(data)).not.toContain(fixture.serviceTokenId) + return data.finalOutput +} +async function waitForRuns(count: number) { + for (let attempt = 0; attempt < 100; attempt++) { + const rows = await runs() + if (rows.length === count && rows.every((row) => row.status === 'completed')) return rows + if (rows.some((row) => row.status === 'failed')) + throw new Error( + `Workflow execution failed: ${JSON.stringify(rows.map((row) => row.executionData))}` + ) + await sleep(100) + } + throw new Error(`Expected ${count} completed workflow executions`) +} + +beforeAll(async () => { + fixture = await createPlanetScaleAppFixture() +}, 240_000) +afterAll(async () => { + if (!fixture) return + const report = + process.env.PLANETSCALE_REPORT_PATH ?? + resolve(process.cwd(), 'test-results/planetscale-http-results.json') + await fixture.report(report, evidence) + await fixture.close() +}, 30_000) + +describe('PlanetScale triggers through the running authenticated Sim application', () => { + it('authorizes draft saving and persists the actual trigger fields', async () => { + const path = `/api/workflows/${fixture.workflowId}/state` + expect((await fixture.request(path, 'PUT', state(), false)).status).toBe(401) + const saved = await fixture.request(path, 'PUT', state()) + expect(saved.status, await saved.clone().text()).toBe(200) + const read = await fixture.request(path) + expect(read.status).toBe(200) + const draft = await read.json() + expect(draft.blocks.trigger.triggerMode).toBe(true) + expect(draft.blocks.trigger.subBlocks.triggerServiceToken.value).toBe(fixture.serviceToken) + expect(fixture.remoteHooks.size).toBe(0) + }, 120_000) + + it('deploys through the outbox and encrypts the provider-generated secret', async () => { + const response = await fixture.request( + `/api/workflows/${fixture.workflowId}/deploy`, + 'POST', + {} + ) + expect(response.status, await response.clone().text()).toBe(200) + const result = await response.json() + expect(result.isDeployed).toBe(true) + const row = await activeHook() + const config = row.providerConfig as Record + expect(config.triggerServiceTokenId).toBe(fixture.serviceTokenId) + expect(config.database).toBe('fixture-db') + expect(config.webhookSecret).not.toBe(fixture.signingSecret) + expect((await decryptSecret(String(config.webhookSecret))).decrypted).toBe( + fixture.signingSecret + ) + expect(fixture.remoteHooks.size).toBe(1) + expect(fixture.providerRequests.filter((request) => request.method === 'POST')).toHaveLength(1) + evidence.deployment = { + status: row.registrationStatus, + prepared: row.preparedAt !== null, + encryptedSecret: true, + } + }, 120_000) + + it('authenticates probes and deliveries, deduplicates spoofed IDs, and stores real typed workflow output', async () => { + const row = await activeHook() + expect( + (await fixture.deliver(row.path!, { ...payload, event: 'webhook.test' }, {}, false)).status + ).toBe(401) + expect((await fixture.deliver(row.path!, { ...payload, event: 'webhook.test' })).status).toBe( + 200 + ) + expect( + (await fixture.deliver(row.path!, { ...payload, event: 'backup.succeeded' })).status + ).toBe(200) + expect(await runs()).toHaveLength(0) + expect((await fixture.deliver(row.path!, payload)).status).toBe(200) + const first = await waitForRuns(1) + expect(await storedOutput(first[0])).toEqual({ + event: payload.event, + timestamp: payload.timestamp, + organization: payload.organization, + database: payload.database, + resource: { + id: 'fixture-branch-id', + name: 'development', + state: 'ready', + ready: true, + production: false, + safeMigrations: false, + parentBranch: 'main', + createdAt: payload.resource.created_at, + updatedAt: payload.resource.updated_at, + htmlUrl: payload.resource.html_url, + }, + payload, + }) + expect( + ( + await fixture.deliver(row.path!, payload, { + 'X-Request-Id': 'spoofed-second-id', + 'X-Sim-Idempotency-Key': 'spoofed-key', + }) + ).status + ).toBe(200) + await sleep(300) + expect(await runs()).toHaveLength(1) + expect( + (await fixture.deliver(row.path!, { ...payload, timestamp: payload.timestamp + 1 })).status + ).toBe(200) + const repeated = await waitForRuns(2) + evidence.executionIds = repeated.map((run) => run.executionId) + evidence.outputs = await Promise.all(repeated.map(storedOutput)) + const accepted = fixture.httpResults.filter( + (request) => request.path.includes('/api/webhooks/trigger') && request.status === 200 + ) + expect(accepted.every((request) => request.durationMs < 2000)).toBe(true) + }, 120_000) + + it('recreates only the changed subscription and undeploys through real cleanup', async () => { + const previous = await activeHook() + expect( + ( + await fixture.request( + `/api/workflows/${fixture.workflowId}/state`, + 'PUT', + state('planetscale_webhook', ['branch.ready', 'backup.succeeded']) + ) + ).status + ).toBe(200) + const updated = await fixture.request(`/api/workflows/${fixture.workflowId}/deploy`, 'POST', {}) + expect(updated.status, await updated.clone().text()).toBe(200) + const current = await activeHook() + expect(current.id).not.toBe(previous.id) + expect(fixture.remoteHooks.size).toBe(1) + expect((current.providerConfig as Record).events).toEqual([ + 'branch.ready', + 'backup.succeeded', + ]) + expect(fixture.providerRequests.filter((request) => request.method === 'POST')).toHaveLength(2) + expect( + fixture.providerRequests.some( + (request) => request.method === 'DELETE' && request.status === 204 + ) + ).toBe(true) + const backup = { + ...payload, + event: 'backup.succeeded', + timestamp: payload.timestamp + 2, + resource: { + id: 'fixture-backup-id', + name: 'Before deploy', + state: 'success', + size: 0, + protected: false, + database_branch: { id: 'fixture-branch-id', name: 'main' }, + }, + } + expect((await fixture.deliver(current.path!, backup)).status).toBe(200) + const genericRuns = await waitForRuns(3) + const outputs = await Promise.all(genericRuns.map(storedOutput)) + expect(outputs).toContainEqual({ ...backup, payload: backup }) + evidence.genericOutput = outputs.find((output) => toRecord(output).event === backup.event) + expect( + (await fixture.deliver(current.path!, { ...payload, event: 'branch.sleeping' })).status + ).toBe(200) + await sleep(300) + expect(await runs()).toHaveLength(3) + const undeployed = await fixture.request( + `/api/workflows/${fixture.workflowId}/deploy`, + 'DELETE' + ) + expect(undeployed.status, await undeployed.clone().text()).toBe(200) + expect(fixture.remoteHooks.size).toBe(0) + expect((await fixture.deliver(current.path!, payload)).status).toBe(404) + evidence.cleanup = { replaced: true, remainingRemoteSubscriptions: fixture.remoteHooks.size } + }, 120_000) +}) diff --git a/apps/sim/lib/webhooks/providers/planetscale.test.ts b/apps/sim/lib/webhooks/providers/planetscale.test.ts new file mode 100644 index 00000000000..2fbdb75c919 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/planetscale.test.ts @@ -0,0 +1,382 @@ +import { createHmac } from 'node:crypto' +import { setEnv } from '@sim/testing/mocks/env.mock' +import { createMockRequest } from '@sim/testing/mocks/request.mock' +import { urlsMockFns } from '@sim/testing/mocks/urls.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { planetscaleHandler } from '@/lib/webhooks/providers/planetscale' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + SubscriptionContext, +} from '@/lib/webhooks/providers/types' + +const credentials = { + triggerServiceTokenId: 'fixture-id', + triggerServiceToken: 'fixture-token', + triggerOrganization: 'fixture-org', + database: 'fixture-db', + triggerId: 'planetscale_branch_ready', +} +const body = { + event: 'branch.ready', + timestamp: 1698252879, + organization: 'fixture-org', + database: 'fixture-db', + resource: { + id: 'branch-id', + name: 'dev', + ready: false, + production: false, + safe_migrations: false, + parent_branch: 'main', + created_at: '2023-10-25T16:54:12.879Z', + }, +} +const rawBody = JSON.stringify(body, null, 2) +const secret = 'fixture-signing-secret' +function authContext( + webhookSecret: unknown, + raw = rawBody, + signature = createHmac('sha256', secret).update(rawBody).digest('hex') +): AuthContext { + return { + webhook: {}, + workflow: {}, + requestId: 'test', + providerConfig: { webhookSecret }, + rawBody: raw, + request: createMockRequest({ + method: 'POST', + rawBody: raw, + headers: { 'X-PlanetScale-Signature': signature }, + }), + } +} +function subscription(config: Record = {}): SubscriptionContext { + return { + webhook: { + id: 'fixture-hook', + path: 'fixture-path', + providerConfig: { ...credentials, ...config }, + }, + workflow: {}, + userId: 'fixture-user', + requestId: 'test', + } +} +function eventContext(config: Record, payload: unknown = body): EventMatchContext { + return { + webhook: {}, + workflow: {}, + body: payload, + request: createMockRequest('POST', payload), + requestId: 'test', + providerConfig: config, + } +} +function formatContext(payload: unknown, triggerId: string): FormatInputContext { + return { + webhook: { providerConfig: { triggerId } }, + workflow: { id: 'fixture-workflow', userId: 'fixture-user' }, + body: payload, + headers: {}, + query: {}, + method: 'POST', + requestId: 'test', + } +} + +describe('PlanetScale signed delivery contracts', () => { + beforeEach(() => setEnv({ ENCRYPTION_KEY: '11'.repeat(32) })) + + it('authenticates encrypted secrets over the exact bytes and rejects parsed-body substitutions', async () => { + const { encrypted } = await encryptSecret(secret) + expect(await planetscaleHandler.verifyAuth!(authContext(encrypted))).toBeNull() + expect( + (await planetscaleHandler.verifyAuth!(authContext(encrypted, JSON.stringify(body))))?.status + ).toBe(401) + }) + + it.each([undefined, '', secret, '00:corrupt:00'])( + 'fails closed for missing, plaintext or corrupt secret: %s', + async (value) => { + expect((await planetscaleHandler.verifyAuth!(authContext(value)))?.status).toBe(401) + } + ) + + it.each(['', 'sha256=1234', 'g'.repeat(64), '0'.repeat(64)])( + 'rejects invalid signature bytes: %s', + async (signature) => { + const { encrypted } = await encryptSecret(secret) + expect( + (await planetscaleHandler.verifyAuth!(authContext(encrypted, rawBody, signature)))?.status + ).toBe(401) + } + ) + + it('filters dedicated and selected generic production events without accepting probes or unknowns', async () => { + expect(await planetscaleHandler.matchEvent!(eventContext(credentials))).toBe(true) + expect( + await planetscaleHandler.matchEvent!( + eventContext(credentials, { ...body, event: 'backup.succeeded' }) + ) + ).toBe(false) + const config = { triggerId: 'planetscale_webhook', events: ['cluster.storage'] } + expect( + await planetscaleHandler.matchEvent!( + eventContext(config, { ...body, event: 'cluster.storage' }) + ) + ).toBe(true) + for (const event of ['branch.ready', 'webhook.test', 'unknown.event']) { + expect(await planetscaleHandler.matchEvent!(eventContext(config, { ...body, event }))).toBe( + false + ) + } + expect( + planetscaleHandler.handleReachabilityTest!({ ...body, event: 'webhook.test' }, 'test')?.status + ).toBe(200) + }) + + it('projects documented branch, backup and deploy fields while preserving false, zero, null and original payload', async () => { + const branch = await planetscaleHandler.formatInput!(formatContext(body, credentials.triggerId)) + expect(branch.input).toMatchObject({ + ...body, + resource: { + id: 'branch-id', + name: 'dev', + ready: false, + production: false, + safeMigrations: false, + parentBranch: 'main', + updatedAt: null, + }, + payload: body, + }) + const backup = { + ...body, + event: 'backup.succeeded', + resource: { + id: 'backup-id', + state: 'success', + size: 0, + protected: false, + database_branch: { id: 'branch-id', name: 'main' }, + }, + } + expect( + (await planetscaleHandler.formatInput!(formatContext(backup, 'planetscale_backup_succeeded'))) + .input + ).toMatchObject({ + resource: { + size: 0, + protected: false, + completedAt: null, + branch: { id: 'branch-id', name: 'main' }, + }, + payload: backup, + }) + const deploy = { + ...body, + event: 'deploy_request.opened', + resource: { + number: 5, + branch: 'dev', + into_branch: 'main', + approved: false, + num_comments: 0, + notes: '', + }, + } + expect( + ( + await planetscaleHandler.formatInput!( + formatContext(deploy, 'planetscale_deploy_request_opened') + ) + ).input + ).toMatchObject({ + resource: { + number: 5, + intoBranch: 'main', + approved: false, + numComments: 0, + notes: '', + closedAt: null, + }, + payload: deploy, + }) + expect( + (await planetscaleHandler.formatInput!(formatContext(deploy, 'planetscale_webhook'))).input + ).toEqual({ ...deploy, payload: deploy }) + }) + + it('rejects malformed envelopes before formatting executable input', async () => { + for (const invalid of [ + null, + { ...body, timestamp: 'now' }, + { ...body, resource: [] }, + { ...body, organization: null }, + ]) { + await expect( + planetscaleHandler.formatInput!(formatContext(invalid, credentials.triggerId)) + ).rejects.toThrow() + } + }) + + it('deduplicates identical authenticated payloads, preserves repeat events and overrides unsigned IDs', () => { + const key = planetscaleHandler.extractIdempotencyId!(body) + expect(key).toBe( + planetscaleHandler.extractIdempotencyId!({ + resource: body.resource, + database: body.database, + organization: body.organization, + timestamp: body.timestamp, + event: body.event, + }) + ) + expect(key).not.toBe( + planetscaleHandler.extractIdempotencyId!({ ...body, timestamp: body.timestamp + 1 }) + ) + const headers = { 'x-sim-idempotency-key': 'attacker', 'x-request-id': 'attacker' } + planetscaleHandler.enrichHeaders!( + { webhook: {}, body, requestId: 'test', providerConfig: credentials }, + headers + ) + expect(headers['x-sim-idempotency-key']).toBe(key) + }) +}) + +describe('PlanetScale subscription resource integrity', () => { + const fetchMock = vi.fn() + beforeEach(() => { + setEnv({ ENCRYPTION_KEY: '11'.repeat(32) }) + urlsMockFns.mockGetBaseUrl.mockReturnValue('http://localhost:3000') + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('creates one subscription and stores only an encrypted signing secret', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ id: 'remote-id', secret })) + const result = await planetscaleHandler.createSubscription!(subscription()) + expect(result?.providerConfigUpdates?.externalId).toBe('remote-id') + const stored = result?.providerConfigUpdates?.webhookSecret + expect(stored).not.toBe(secret) + expect(await decryptSecret(String(stored))).toEqual({ decrypted: secret }) + expect(JSON.stringify(result)).not.toContain(credentials.triggerServiceToken) + }) + + it('recovers an exact checkpoint instead of creating a second subscription, even with corrupt old ciphertext', async () => { + const callback = 'http://localhost:3000/api/webhooks/trigger/fixture-path' + fetchMock.mockResolvedValueOnce( + Response.json({ + id: 'remote-id', + url: callback, + events: ['branch.ready'], + enabled: true, + secret, + }) + ) + const result = await planetscaleHandler.createSubscription!( + subscription({ externalId: 'remote-id', webhookSecret: 'corrupt' }) + ) + expect(result?.providerConfigUpdates?.externalId).toBe('remote-id') + expect(await decryptSecret(String(result?.providerConfigUpdates?.webhookSecret))).toEqual({ + decrypted: secret, + }) + expect( + fetchMock.mock.calls.map(([url, init]) => [new URL(String(url)).pathname, init?.method]) + ).toEqual([['/v1/organizations/fixture-org/databases/fixture-db/webhooks/remote-id', 'GET']]) + }) + + it.each([401, 403, 500])( + 'does not replace a recorded subscription after HTTP %s', + async (status) => { + fetchMock.mockResolvedValueOnce( + Response.json({ error: credentials.triggerServiceToken }, { status }) + ) + await expect( + planetscaleHandler.createSubscription!(subscription({ externalId: 'remote-id' })) + ).rejects.toThrow() + expect(fetchMock.mock.calls).toHaveLength(1) + } + ) + + it('creates a replacement only after an exact-ID 404', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })) + fetchMock.mockResolvedValueOnce(Response.json({ id: 'replacement', secret })) + expect( + (await planetscaleHandler.createSubscription!(subscription({ externalId: 'removed-id' }))) + ?.providerConfigUpdates?.externalId + ).toBe('replacement') + expect(fetchMock.mock.calls.map(([, init]) => init?.method)).toEqual(['GET', 'POST']) + }) + + it('cleans up only the returned ID when creation cannot produce usable signing state', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ id: 'created-id', secret: '' })) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + await expect(planetscaleHandler.createSubscription!(subscription())).rejects.toThrow() + expect( + fetchMock.mock.calls.map(([url, init]) => [new URL(String(url)).pathname, init?.method]) + ).toEqual([ + ['/v1/organizations/fixture-org/databases/fixture-db/webhooks', 'POST'], + ['/v1/organizations/fixture-org/databases/fixture-db/webhooks/created-id', 'DELETE'], + ]) + }) + + it.each([204, 404])('treats deletion HTTP %s as completed', async (status) => { + fetchMock.mockResolvedValueOnce(new Response(null, { status })) + await expect( + planetscaleHandler.deleteSubscription!({ + ...subscription({ externalId: 'remote-id' }), + strict: true, + }) + ).resolves.toBeUndefined() + }) + + it.each(['.', '..', ''])( + 'rejects malformed returned IDs without deleting a collection or parent: %s', + async (id) => { + fetchMock.mockResolvedValueOnce(Response.json({ id, secret })) + await expect(planetscaleHandler.createSubscription!(subscription())).rejects.toThrow() + expect(fetchMock.mock.calls).toHaveLength(1) + } + ) + + it('retains strict cleanup failures for retry without leaking transport or provider secrets', async () => { + fetchMock.mockRejectedValueOnce(new Error(`Authorization ${credentials.triggerServiceToken}`)) + await expect( + planetscaleHandler.deleteSubscription!({ + ...subscription({ externalId: 'remote-id' }), + strict: true, + }) + ).rejects.toThrow('PlanetScale') + fetchMock.mockResolvedValueOnce( + Response.json({ token: credentials.triggerServiceToken }, { status: 403 }) + ) + try { + await planetscaleHandler.deleteSubscription!({ + ...subscription({ externalId: 'remote-id' }), + strict: true, + }) + expect.fail('Strict cleanup must fail') + } catch (error) { + expect(String(error)).not.toContain(credentials.triggerServiceToken) + } + await expect( + planetscaleHandler.deleteSubscription!({ ...subscription(), strict: true }) + ).rejects.toThrow() + await expect(planetscaleHandler.deleteSubscription!(subscription())).resolves.toBeUndefined() + }) + + it('rejects empty or unknown generic selections without creating a subscription', async () => { + for (const events of [[], ['unknown.event'], ['webhook.test']]) { + await expect( + planetscaleHandler.createSubscription!( + subscription({ triggerId: 'planetscale_webhook', events }) + ) + ).rejects.toThrow() + } + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/planetscale.ts b/apps/sim/lib/webhooks/providers/planetscale.ts new file mode 100644 index 00000000000..3ce67a0d502 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/planetscale.ts @@ -0,0 +1,320 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' +import { isRecordLike, toRecord } from '@sim/utils/object' +import { NextResponse } from 'next/server' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { + buildFallbackDeliveryFingerprint, + createHmacVerifier, +} from '@/lib/webhooks/providers/utils' +import { safeUrlPathSegment } from '@/tools/url-path' + +const logger = createLogger('WebhookProvider:PlanetScale') +const MANAGEMENT_TIMEOUT_MS = 15_000 +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + +async function validatePlanetScaleSignature( + encrypted: string, + signature: string, + rawBody: string +): Promise { + if (!/^[a-fA-F0-9]{64}$/.test(signature)) return false + try { + const { decrypted } = await decryptSecret(encrypted, { logFailure: false }) + return !!decrypted && safeCompare(hmacSha256Hex(rawBody, decrypted), signature.toLowerCase()) + } catch { + return false + } +} + +function requiredValue(config: Record, key: string): string { + const value = config[key] + if ( + typeof value !== 'string' || + !value.trim() || + /[\r\n]/.test(value) || + value.includes('{{') || + value === '.' || + value === '..' + ) { + throw new Error( + 'PlanetScale service token credentials, organization and database are required. Resolve environment references before registration.' + ) + } + return value +} + +function managementContext(config: Record) { + const id = requiredValue(config, 'triggerServiceTokenId') + const token = requiredValue(config, 'triggerServiceToken') + if (id.includes(':') || token.includes(':')) + throw new Error('Invalid PlanetScale service token credentials.') + const organization = requiredValue(config, 'triggerOrganization') + const database = requiredValue(config, 'database') + return { + url: `https://api.planetscale.com/v1/organizations/${safeUrlPathSegment(organization, 'organization')}/databases/${safeUrlPathSegment(database, 'database')}/webhooks`, + authorization: `${id}:${token}`, + } +} + +class PlanetScaleManagementError extends Error {} + +function managementFailure(status?: number): Error { + if (status === 401) + return new PlanetScaleManagementError( + 'PlanetScale authentication failed. Check the service token ID and token.' + ) + if (status === 403) + return new PlanetScaleManagementError( + 'PlanetScale access denied. Grant read_database and write_database permissions for this database.' + ) + if (status === 422) + return new PlanetScaleManagementError( + 'PlanetScale rejected the webhook configuration. Check the events, HTTPS callback and five-webhook limit.' + ) + return new PlanetScaleManagementError( + status === undefined + ? 'PlanetScale webhook management request failed.' + : `PlanetScale webhook management request failed (HTTP ${status}).` + ) +} + +async function managementRequest( + config: Record, + method: 'GET' | 'POST' | 'DELETE', + externalId?: string, + body?: Record +): Promise<{ status: number; data: Record }> { + const context = managementContext(config) + const signal = AbortSignal.timeout(MANAGEMENT_TIMEOUT_MS) + try { + const response = await fetch( + `${context.url}${externalId ? `/${safeUrlPathSegment(externalId, 'webhook ID')}` : ''}`, + { + method, + redirect: 'error', + headers: { + Authorization: context.authorization, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal, + } + ) + if (response.status === 404 || (method === 'DELETE' && response.status === 204)) { + await response.body?.cancel() + return { status: response.status, data: {} } + } + if (!response.ok) { + await response.body?.cancel() + throw managementFailure(response.status) + } + if (method === 'DELETE') { + await response.body?.cancel() + throw managementFailure(response.status) + } + const data = await readResponseJsonWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + label: 'PlanetScale webhook management response', + signal, + }) + if (!isRecordLike(data)) throw managementFailure() + return { status: response.status, data } + } catch (error) { + if (error instanceof PlanetScaleManagementError) throw error + throw managementFailure() + } +} + +async function secretUpdates(data: Record): Promise { + if (typeof data.id !== 'string' || !data.id.trim()) + throw new Error('PlanetScale webhook response has no valid ID.') + safeUrlPathSegment(data.id, 'webhook ID') + if (typeof data.secret !== 'string' || !data.secret.trim()) + throw new Error('PlanetScale webhook response has no signing secret.') + const { encrypted } = await encryptSecret(data.secret) + return { providerConfigUpdates: { externalId: data.id, webhookSecret: encrypted } } +} + +function projectResource( + resource: Record, + triggerId: unknown +): Record { + if (triggerId === 'planetscale_webhook') return resource + const identity = { + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + state: toStringOrNull(resource.state), + createdAt: toStringOrNull(resource.created_at), + updatedAt: toStringOrNull(resource.updated_at), + } + if (typeof triggerId === 'string' && triggerId.startsWith('planetscale_branch_')) + return { + ...identity, + ready: toBooleanOrNull(resource.ready), + production: toBooleanOrNull(resource.production), + safeMigrations: toBooleanOrNull(resource.safe_migrations), + parentBranch: toStringOrNull(resource.parent_branch), + htmlUrl: toStringOrNull(resource.html_url), + } + if (typeof triggerId === 'string' && triggerId.startsWith('planetscale_backup_')) { + const branch = toRecord(resource.database_branch) + return { + ...identity, + size: toNumberOrNull(resource.size), + protected: toBooleanOrNull(resource.protected), + startedAt: toStringOrNull(resource.started_at), + completedAt: toStringOrNull(resource.completed_at), + expiresAt: toStringOrNull(resource.expires_at), + branch: { id: toStringOrNull(branch.id), name: toStringOrNull(branch.name) }, + } + } + return { + id: identity.id, + state: identity.state, + createdAt: identity.createdAt, + updatedAt: identity.updatedAt, + number: toNumberOrNull(resource.number), + branch: toStringOrNull(resource.branch), + intoBranch: toStringOrNull(resource.into_branch), + deploymentState: toStringOrNull(resource.deployment_state), + approved: toBooleanOrNull(resource.approved), + numComments: toNumberOrNull(resource.num_comments), + notes: toStringOrNull(resource.notes), + closedAt: toStringOrNull(resource.closed_at), + deployedAt: toStringOrNull(resource.deployed_at), + htmlUrl: toStringOrNull(resource.html_url), + } +} + +export const planetscaleHandler: WebhookProviderHandler = { + executionMode: 'queue', + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-PlanetScale-Signature', + requireSecret: true, + providerLabel: 'PlanetScale', + validateFn: validatePlanetScaleSignature, + }), + + handleReachabilityTest(body) { + return toRecord(body).event === 'webhook.test' + ? NextResponse.json({ message: 'PlanetScale webhook test acknowledged' }) + : null + }, + + async matchEvent({ body, providerConfig }): Promise { + const event = toRecord(body).event + if (typeof event !== 'string' || event === 'webhook.test') return false + const { getPlanetScaleEvents } = await import('@/triggers/planetscale/utils') + try { + return getPlanetScaleEvents(providerConfig).includes(event) + } catch { + return false + } + }, + + /** Retain signed native event time to distinguish repeated resource snapshots; no receipt time is added. */ + extractIdempotencyId(body) { + return `planetscale:${buildFallbackDeliveryFingerprint(body)}` + }, + + enrichHeaders({ body }, headers) { + headers['x-sim-idempotency-key'] = `planetscale:${buildFallbackDeliveryFingerprint(body)}` + }, + + async formatInput({ body, webhook }) { + if ( + !isRecordLike(body) || + typeof body.event !== 'string' || + !Number.isSafeInteger(body.timestamp) || + typeof body.organization !== 'string' || + typeof body.database !== 'string' || + !isRecordLike(body.resource) + ) { + throw new Error('Invalid PlanetScale webhook payload.') + } + return { + input: { + event: body.event, + timestamp: body.timestamp, + organization: body.organization, + database: body.database, + resource: projectResource(body.resource, getProviderConfig(webhook).triggerId), + payload: body, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const { getPlanetScaleEvents } = await import('@/triggers/planetscale/utils') + const events = getPlanetScaleEvents(config) + const callback = getNotificationUrl(ctx.webhook) + if (config.externalId !== undefined && config.externalId !== null) { + const externalId = requiredValue(config, 'externalId') + const existing = await managementRequest(config, 'GET', externalId) + if (existing.status !== 404) { + const data = existing.data + if ( + data.id !== externalId || + data.url !== callback || + data.enabled !== true || + !Array.isArray(data.events) || + data.events.length !== events.length || + !events.every((event) => (data.events as unknown[]).includes(event)) + ) { + throw new Error( + 'PlanetScale recorded webhook does not match this registration. Check its callback, events and enabled state.' + ) + } + try { + return await secretUpdates(data) + } catch { + throw new Error('PlanetScale recorded webhook has no usable signing secret.') + } + } + } + const { data, status } = await managementRequest(config, 'POST', undefined, { + url: callback, + enabled: true, + events, + }) + if (status === 404) throw managementFailure(status) + try { + return await secretUpdates(data) + } catch { + if ( + typeof data.id === 'string' && + data.id.trim() && + data.id.trim() !== '.' && + data.id.trim() !== '..' + ) + await managementRequest(config, 'DELETE', data.id) + throw new Error('PlanetScale webhook creation did not return usable signing state.') + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const externalId = requiredValue(config, 'externalId') + await managementRequest(config, 'DELETE', externalId) + } catch { + if (ctx.strict) + throw new Error('PlanetScale webhook deletion failed. Check credentials and retry cleanup.') + logger.warn(`[${ctx.requestId}] PlanetScale webhook cleanup failed`) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 9a690cb9e27..e8e68b99359 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -41,6 +41,7 @@ import { notionHandler } from '@/lib/webhooks/providers/notion' import { otterHandler } from '@/lib/webhooks/providers/otter' import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty' +import { planetscaleHandler } from '@/lib/webhooks/providers/planetscale' import { quickBooksHandler } from '@/lib/webhooks/providers/quickbooks' import { resendHandler } from '@/lib/webhooks/providers/resend' import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat' @@ -115,6 +116,7 @@ const PROVIDER_HANDLERS: Record = { otter: otterHandler, outlook: outlookHandler, pagerduty: pagerdutyHandler, + planetscale: planetscaleHandler, quickbooks: quickBooksHandler, rss: rssHandler, salesforce: salesforceHandler, diff --git a/apps/sim/lib/webhooks/registration-service.test.ts b/apps/sim/lib/webhooks/registration-service.test.ts index 42059607c5f..2b0728f38e1 100644 --- a/apps/sim/lib/webhooks/registration-service.test.ts +++ b/apps/sim/lib/webhooks/registration-service.test.ts @@ -11,6 +11,7 @@ vi.mock('@/lib/webhooks/providers', () => ({ })) import type { NextRequest } from 'next/server' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { cleanupRetiredWebhookRegistrationsAfterActivation, prepareStableWebhookRegistrations, @@ -240,6 +241,92 @@ describe('stable webhook registration service', () => { expect(checkpointCandidate.mock.calls[1][0]).not.toHaveProperty('prepared') }) + it.each(['external', 'final', 'polling', 'superseded'])( + 'rolls back only the current candidate after a %s checkpoint failure', + async (phase) => { + const candidate = registrationRow({ + registrationStatus: 'candidate', + registrationGeneration: fence.generation, + preparedAt: null, + providerConfig: { event: 'updated' }, + isActive: false, + }) + const desired = { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: 'events', + routingKey: null, + providerConfig: { event: 'updated' }, + desiredConfig: { event: 'updated' }, + configFingerprint: 'new-fingerprint', + } + const subscriptions = new Set(['external-live']) + let nextId = 0 + let checkpoint = 0 + const failedCheckpoint = phase === 'final' || phase === 'polling' ? 2 : 1 + const handler = providerHandler as WebhookProviderHandler + if (phase === 'polling') { + handler.configurePolling = async ({ webhook: row, persistProviderConfig }) => { + const config = row.providerConfig as Record + subscriptions.delete(String(config.externalId)) + const externalId = `${config.externalId}-polling` + subscriptions.add(externalId) + await persistProviderConfig!({ ...config, externalId }) + return true + } + } + const store = dependencies({ + prepareIntents: vi.fn().mockResolvedValue({ + candidates: [{ desired, row: candidate }], + orphanedCandidates: [], + }), + createExternal: vi.fn(async () => { + const externalId = `external-candidate-${++nextId}` + subscriptions.add(externalId) + return { + updatedProviderConfig: { ...desired.providerConfig, externalId }, + externalSubscriptionCreated: true, + } + }), + checkpointCandidate: vi.fn(async () => { + if (++checkpoint === failedCheckpoint) throw new Error('Checkpoint unavailable') + return candidate + }), + getCleanupSnapshot: vi.fn().mockResolvedValue(phase === 'superseded' ? null : candidate), + cleanupExternal: vi.fn(async (row, _workflow, _requestId, options) => { + if (!options?.throwOnError) throw new Error('Rollback must require successful cleanup') + subscriptions.delete(String((row.providerConfig as Record).externalId)) + }), + }) + const input = { + request: {} as NextRequest, + fence, + workflow: { id: fence.workflowId }, + userId: 'user-1', + requestId: 'request-checkpoint', + desired: [desired], + } + + try { + await expect(prepareStableWebhookRegistrations(input, store)).rejects.toThrow( + 'Checkpoint unavailable' + ) + if (phase === 'superseded') { + expect([...subscriptions]).toEqual(['external-live', 'external-candidate-1']) + } else { + expect([...subscriptions]).toEqual(['external-live']) + await prepareStableWebhookRegistrations(input, store) + expect([...subscriptions]).toEqual([ + 'external-live', + phase === 'polling' ? 'external-candidate-2-polling' : 'external-candidate-2', + ]) + } + } finally { + handler.configurePolling = undefined + } + } + ) + it('cleans a never-prepared ghost candidate best-effort so new deploys are not wedged', async () => { const ghostOrphan = registrationRow({ id: 'ghost-orphan', diff --git a/apps/sim/lib/webhooks/registration-service.ts b/apps/sim/lib/webhooks/registration-service.ts index a32ab8932dd..484d53eb4da 100644 --- a/apps/sim/lib/webhooks/registration-service.ts +++ b/apps/sim/lib/webhooks/registration-service.ts @@ -123,7 +123,8 @@ async function cleanupGenerationFencedRegistration( async function createCandidateProviderState( input: PrepareStableWebhookRegistrationsInput, candidate: PreparedWebhookCandidate, - dependencies: StableWebhookRegistrationDependencies + dependencies: StableWebhookRegistrationDependencies, + captureProviderState: (config: Record) => void ): Promise> { const webhookData = { ...candidate.row, @@ -141,6 +142,8 @@ async function createCandidateProviderState( { signal: input.signal } ) let providerConfig = externalResult.updatedProviderConfig + // Rollback needs the returned ID even if the first durable checkpoint fails. + captureProviderState(providerConfig) if (externalResult.externalSubscriptionCreated) { /** @@ -169,6 +172,7 @@ async function createCandidateProviderState( deploymentVersionId: input.fence.deploymentVersionId, persistProviderConfig: async (configuredProviderConfig) => { persistedProviderConfig = configuredProviderConfig + captureProviderState(configuredProviderConfig) await dependencies.checkpointCandidate({ fence: input.fence, webhookId: candidate.row.id, @@ -250,7 +254,14 @@ async function prepareCandidate( } input.signal?.throwIfAborted() - preparedProviderConfig = await createCandidateProviderState(input, candidate, dependencies) + preparedProviderConfig = await createCandidateProviderState( + input, + candidate, + dependencies, + (config) => { + preparedProviderConfig = config + } + ) input.signal?.throwIfAborted() await dependencies.checkpointCandidate({ fence: input.fence, diff --git a/apps/sim/lib/webhooks/registration-store.test.ts b/apps/sim/lib/webhooks/registration-store.test.ts index be5f1d84f31..5297edfcb76 100644 --- a/apps/sim/lib/webhooks/registration-store.test.ts +++ b/apps/sim/lib/webhooks/registration-store.test.ts @@ -1,4 +1,5 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsDeploymentOperationCurrent, mockClaimWebhookPath } = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ import type { DbOrTx } from '@sim/workflow-persistence/types' import { activateWebhookRegistrations, + checkpointWebhookCandidate, prepareWebhookRegistrationIntents, StaleWebhookRegistrationOperationError, type WebhookRegistrationOperationFence, @@ -383,3 +385,59 @@ describe('redeploys racing within seconds', () => { ) }) }) + +describe('registration persistence confidentiality', () => { + beforeEach(resetDbChainMock) + it('preserves non-database domain errors for deployment classification', async () => { + const error = Object.assign(new Error('Webhook path is already claimed'), { + code: 'webhook_path_conflict', + }) + dbChainMockFns.transaction.mockRejectedValueOnce(error) + await expect(prepareWebhookRegistrationIntents({ fence: FENCE, desired: [] })).rejects.toBe( + error + ) + }) + + it.each( + ['intent', 'checkpoint'].flatMap((phase) => + ['23505', '57014', '08006', 'fixture-secret-code'].map((code) => [phase, code]) + ) + )( + 'projects credential-bearing database errors into safe %s failures with code %s', + async (phase, code) => { + const credential = 'fixture-registration-secret-must-never-escape' + const databaseError = new DrizzleQueryError( + 'insert webhook values ($1)', + [JSON.stringify({ token: credential })], + Object.assign(new Error(credential), { code }) + ) + dbChainMockFns.transaction.mockRejectedValueOnce(databaseError) + dbChainMockFns.returning.mockRejectedValueOnce(databaseError) + const operation = + phase === 'intent' + ? prepareWebhookRegistrationIntents({ fence: FENCE, desired: [] }) + : checkpointWebhookCandidate({ + fence: FENCE, + webhookId: 'fixture-id', + providerConfig: { token: credential }, + }) + const error: unknown = await operation.then( + () => undefined, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBe(databaseError) + expect(String(error)).not.toContain(credential) + expect(JSON.stringify(error)).not.toContain(credential) + expect((error as Error).cause).toBeUndefined() + expect((error as Error).stack).not.toContain(credential) + if (code === 'fixture-secret-code') { + expect(JSON.stringify(error)).not.toContain(code) + expect(String(error)).not.toContain(code) + } else { + expect((error as Error & { code?: string }).code).toBe(code) + expect(String(error)).toContain(code) + } + } + ) +}) diff --git a/apps/sim/lib/webhooks/registration-store.ts b/apps/sim/lib/webhooks/registration-store.ts index eeeb1e350f6..a72491e68e2 100644 --- a/apps/sim/lib/webhooks/registration-store.ts +++ b/apps/sim/lib/webhooks/registration-store.ts @@ -5,10 +5,12 @@ import { workflowDeploymentOperation, workflowDeploymentVersion, } from '@sim/db/schema' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import type { DbOrTx } from '@sim/workflow-persistence/types' import { and, eq, exists, gt, inArray, isNull, lt, lte, notExists, sql } from 'drizzle-orm' +import { findDatabaseQueryError } from '@/lib/core/errors/database-query-error' import { claimWebhookPath } from '@/lib/webhooks/path-claims' import { projectDesiredWebhookProviderConfig } from '@/lib/webhooks/provider-subscriptions' import { @@ -60,6 +62,15 @@ export class StaleWebhookRegistrationOperationError extends Error { } } +/** Preserve the SQLSTATE without retaining credential-bearing query or driver details. */ +function safeRegistrationPersistenceError(error: unknown, message: string): Error { + const code = getPostgresErrorCode(error) + if (code && /^[0-9A-Z]{5}$/.test(code)) { + return Object.assign(new Error(`${message} (SQLSTATE ${code}).`), { code }) + } + return new Error(`${message}.`) +} + function assertOperationGeneration(generation: number): void { if (!Number.isSafeInteger(generation) || generation <= 0) { throw new TypeError('Webhook registration generation must be a positive safe integer') @@ -223,188 +234,200 @@ export async function prepareWebhookRegistrationIntents(input: { fence: WebhookRegistrationOperationFence desired: readonly DesiredWebhookRegistrationIntent[] }): Promise { - return db.transaction(async (tx) => { - await setDeploymentTxTimeouts(tx) - await assertCurrentOperation(tx, input.fence, ['preparing']) - await adoptLegacyActiveRows(tx, input.fence) - - for (const desired of input.desired) { - if (desired.path) { - await claimWebhookPath(tx, { - path: desired.path, - workflowId: input.fence.workflowId, - generation: input.fence.generation, - }) + return db + .transaction(async (tx) => { + await setDeploymentTxTimeouts(tx) + await assertCurrentOperation(tx, input.fence, ['preparing']) + await adoptLegacyActiveRows(tx, input.fence) + + for (const desired of input.desired) { + if (desired.path) { + await claimWebhookPath(tx, { + path: desired.path, + workflowId: input.fence.workflowId, + generation: input.fence.generation, + }) + } } - } - const activeRows = await tx - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, input.fence.workflowId), - eq(webhook.registrationStatus, 'active'), - eq(webhook.isActive, true), - isNull(webhook.archivedAt) + const activeRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'active'), + eq(webhook.isActive, true), + isNull(webhook.archivedAt) + ) ) - ) - const activeRegistrations = activeRows - .filter( - (row): row is WebhookRegistrationRow & { blockId: string } => - typeof row.blockId === 'string' - ) - .map((row) => ({ - triggerId: row.blockId, - generation: rowRegistrationGeneration(row), - fingerprint: row.configFingerprint, - row, - })) - - const plan = planWebhookRegistrationReconciliation({ - generation: input.fence.generation, - desired: input.desired.map((desired) => ({ - triggerId: desired.blockId, - fingerprint: desired.configFingerprint, - desired, - })), - existing: activeRegistrations, - }) - - const candidateRows = await tx - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, input.fence.workflowId), - eq(webhook.registrationStatus, 'candidate') - ) - ) - const candidatesByBlockId = new Map( - candidateRows + const activeRegistrations = activeRows .filter( (row): row is WebhookRegistrationRow & { blockId: string } => typeof row.blockId === 'string' ) - .map((row) => [row.blockId, row]) - ) + .map((row) => ({ + triggerId: row.blockId, + generation: rowRegistrationGeneration(row), + fingerprint: row.configFingerprint, + row, + })) + + const plan = planWebhookRegistrationReconciliation({ + generation: input.fence.generation, + desired: input.desired.map((desired) => ({ + triggerId: desired.blockId, + fingerprint: desired.configFingerprint, + desired, + })), + existing: activeRegistrations, + }) - /** - * Orphans left by earlier attempts (their cleanup failed or the process - * died) are re-collected on every preparation so they cannot leak forever - * — cleanup itself stays generation-fenced, so racing operations at most - * duplicate a best-effort provider delete. - */ - const staleOrphanRows = await tx - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, input.fence.workflowId), - eq(webhook.registrationStatus, 'orphaned') + const candidateRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'candidate') + ) ) + const candidatesByBlockId = new Map( + candidateRows + .filter( + (row): row is WebhookRegistrationRow & { blockId: string } => + typeof row.blockId === 'string' + ) + .map((row) => [row.blockId, row]) ) - const candidates: PreparedWebhookCandidate[] = [] - const orphanedCandidates: WebhookRegistrationRow[] = [...staleOrphanRows] - const now = new Date() - - for (const action of plan.actions) { - if (action.kind === 'reuse') { - const currentGeneration = rowRegistrationGeneration(action.existing.row) - const [updated] = await tx - .update(webhook) - .set({ - registrationGeneration: input.fence.generation, - configFingerprint: action.desired.fingerprint, - preparedAt: now, - updatedAt: now, - }) - .where( - and( - eq(webhook.id, action.existing.row.id), - eq(webhook.registrationStatus, 'active'), - eq(webhook.registrationGeneration, currentGeneration), - lte(webhook.registrationGeneration, input.fence.generation) - ) + /** + * Orphans left by earlier attempts (their cleanup failed or the process + * died) are re-collected on every preparation so they cannot leak forever + * — cleanup itself stays generation-fenced, so racing operations at most + * duplicate a best-effort provider delete. + */ + const staleOrphanRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'orphaned') ) - .returning() - if (!updated) throw new StaleWebhookRegistrationOperationError() - continue - } + ) - const desired = action.desired.desired - const existingCandidate = candidatesByBlockId.get(action.triggerId) - if (existingCandidate && existingCandidate.configFingerprint === action.desired.fingerprint) { - if (existingCandidate.registrationGeneration === input.fence.generation) { - candidates.push({ desired, row: existingCandidate }) + const candidates: PreparedWebhookCandidate[] = [] + const orphanedCandidates: WebhookRegistrationRow[] = [...staleOrphanRows] + const now = new Date() + + for (const action of plan.actions) { + if (action.kind === 'reuse') { + const currentGeneration = rowRegistrationGeneration(action.existing.row) + const [updated] = await tx + .update(webhook) + .set({ + registrationGeneration: input.fence.generation, + configFingerprint: action.desired.fingerprint, + preparedAt: now, + updatedAt: now, + }) + .where( + and( + eq(webhook.id, action.existing.row.id), + eq(webhook.registrationStatus, 'active'), + eq(webhook.registrationGeneration, currentGeneration), + lte(webhook.registrationGeneration, input.fence.generation) + ) + ) + .returning() + if (!updated) throw new StaleWebhookRegistrationOperationError() continue } - /** - * A fingerprint-identical candidate from a superseded attempt is - * adopted rather than orphaned and reinserted: this preserves any - * checkpointed provider progress (an external subscription it already - * created keeps serving instead of being deleted and recreated) and - * avoids insert churn against the path uniqueness index. - */ - const [adopted] = await tx - .update(webhook) - .set({ - registrationGeneration: input.fence.generation, - deploymentVersionId: input.fence.deploymentVersionId, - updatedAt: now, - }) - .where( - and( - eq(webhook.id, existingCandidate.id), - eq(webhook.registrationStatus, 'candidate'), - eq(webhook.registrationGeneration, rowRegistrationGeneration(existingCandidate)) + + const desired = action.desired.desired + const existingCandidate = candidatesByBlockId.get(action.triggerId) + if ( + existingCandidate && + existingCandidate.configFingerprint === action.desired.fingerprint + ) { + if (existingCandidate.registrationGeneration === input.fence.generation) { + candidates.push({ desired, row: existingCandidate }) + continue + } + /** + * A fingerprint-identical candidate from a superseded attempt is + * adopted rather than orphaned and reinserted: this preserves any + * checkpointed provider progress (an external subscription it already + * created keeps serving instead of being deleted and recreated) and + * avoids insert churn against the path uniqueness index. + */ + const [adopted] = await tx + .update(webhook) + .set({ + registrationGeneration: input.fence.generation, + deploymentVersionId: input.fence.deploymentVersionId, + updatedAt: now, + }) + .where( + and( + eq(webhook.id, existingCandidate.id), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, rowRegistrationGeneration(existingCandidate)) + ) ) - ) - .returning() - if (!adopted) throw new StaleWebhookRegistrationOperationError() - candidates.push({ desired, row: adopted }) - continue - } + .returning() + if (!adopted) throw new StaleWebhookRegistrationOperationError() + candidates.push({ desired, row: adopted }) + continue + } - if (existingCandidate) { - const existingGeneration = rowRegistrationGeneration(existingCandidate) - const [orphaned] = await tx - .update(webhook) - .set({ - registrationStatus: 'orphaned', - updatedAt: now, - }) - .where( - and( - eq(webhook.id, existingCandidate.id), - eq(webhook.registrationStatus, 'candidate'), - eq(webhook.registrationGeneration, existingGeneration) + if (existingCandidate) { + const existingGeneration = rowRegistrationGeneration(existingCandidate) + const [orphaned] = await tx + .update(webhook) + .set({ + registrationStatus: 'orphaned', + updatedAt: now, + }) + .where( + and( + eq(webhook.id, existingCandidate.id), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, existingGeneration) + ) ) + .returning() + if (!orphaned) throw new StaleWebhookRegistrationOperationError() + orphanedCandidates.push(orphaned) + } + + const [candidate] = await tx + .insert(webhook) + .values( + buildLegacyInvisibleCandidateValues({ + id: generateShortId(), + fence: input.fence, + desired, + now, + }) ) .returning() - if (!orphaned) throw new StaleWebhookRegistrationOperationError() - orphanedCandidates.push(orphaned) + if (!candidate) throw new Error('Failed to persist webhook registration candidate') + candidates.push({ desired, row: candidate }) } - const [candidate] = await tx - .insert(webhook) - .values( - buildLegacyInvisibleCandidateValues({ - id: generateShortId(), - fence: input.fence, - desired, - now, - }) - ) - .returning() - if (!candidate) throw new Error('Failed to persist webhook registration candidate') - candidates.push({ desired, row: candidate }) - } - - return { candidates, orphanedCandidates } - }) + return { candidates, orphanedCandidates } + }) + .catch((error: unknown) => { + const queryError = findDatabaseQueryError(error) + if (error instanceof StaleWebhookRegistrationOperationError || !queryError) throw error + throw safeRegistrationPersistenceError( + queryError, + 'Failed to persist webhook registration intent' + ) + }) } /** @@ -481,6 +504,14 @@ export async function checkpointWebhookCandidate(input: { ) ) .returning() + .catch((error: unknown) => { + const queryError = findDatabaseQueryError(error) + if (!queryError) throw error + throw safeRegistrationPersistenceError( + queryError, + 'Failed to checkpoint webhook registration state' + ) + }) if (!updated) throw new StaleWebhookRegistrationOperationError() return updated } diff --git a/apps/sim/lib/workflows/blocks/canvas-sentence-validation.test.ts b/apps/sim/lib/workflows/blocks/canvas-sentence-validation.test.ts index c15c9364fd3..b320df5330d 100644 --- a/apps/sim/lib/workflows/blocks/canvas-sentence-validation.test.ts +++ b/apps/sim/lib/workflows/blocks/canvas-sentence-validation.test.ts @@ -79,6 +79,18 @@ describe('canonical-pair completeness', () => { { id: 'manualTableId', canonicalParamId: 'tableId', mode: 'advanced' as const }, ] + it('keeps inactive trigger members out of action sentence canonical pairs', () => { + const config = createConfig( + { default: [{ text: 'Query', field: ['tableSelector', 'manualTableId'], core: true }] }, + [ + ...pairSubBlocks, + { id: 'triggerTableSelector', canonicalParamId: 'tableId', mode: 'trigger' }, + { id: 'triggerManualTableId', canonicalParamId: 'tableId', mode: 'trigger-advanced' }, + ] + ) + expect(messages(config)).toEqual([]) + }) + it('rejects a clause naming only the basic member', () => { const config = createConfig( { default: [{ text: 'Query', field: 'tableSelector', core: true }] }, diff --git a/apps/sim/lib/workflows/blocks/canvas-sentence-validation.ts b/apps/sim/lib/workflows/blocks/canvas-sentence-validation.ts index 7f0ce41f925..08fdf606a8f 100644 --- a/apps/sim/lib/workflows/blocks/canvas-sentence-validation.ts +++ b/apps/sim/lib/workflows/blocks/canvas-sentence-validation.ts @@ -228,9 +228,10 @@ function buildBlockIndex(config: ValidatableBlockConfig): BlockIndex { return { subBlocks: config.subBlocks, byId: groupSubBlocksById(config.subBlocks), - // canonical-index-unscoped: `resolveVisibility` returns `hidden` for every trigger-mode - // subblock as its first check, so only action subblocks ever reach this index. - canonical: buildCanonicalIndex(config.subBlocks), + // canonical-index-unscoped: this action-sentence validator explicitly filters every trigger-mode field. + canonical: buildCanonicalIndex( + config.subBlocks.filter((field) => !isTriggerModeSubBlock(field)) + ), seededValues: getSeededSubBlockValues(config), } } diff --git a/apps/sim/lib/workflows/subblocks/visibility.test.ts b/apps/sim/lib/workflows/subblocks/visibility.test.ts index 51029f833df..ac9e622de4c 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.test.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.test.ts @@ -244,6 +244,41 @@ describe('canonical index scoping by surface', () => { { id: 'triggerSiteId', type: 'dropdown', canonicalParamId: 'siteId', mode: 'trigger' }, ] as SubBlockConfig[] + it.concurrent( + 'does not let inactive trigger aliases replace concrete action dependencies', + () => { + const fields = [ + { id: 'serviceToken', type: 'short-input' }, + { + id: 'triggerServiceToken', + type: 'short-input', + canonicalParamId: 'serviceToken', + mode: 'trigger', + }, + ...MIXED, + ] as SubBlockConfig[] + const values = { + serviceToken: 'action-secret', + triggerServiceToken: 'stale-trigger-secret', + siteSelector: 'action-site', + triggerSiteId: 'stale-trigger-site', + } + const action = buildCanonicalIndexForSurface(fields, false) + expect(resolveActiveDependencyValue('serviceToken', values, action)).toBe('action-secret') + expect( + resolveActiveDependencyValue('serviceToken', { ...values, serviceToken: undefined }, action) + ).toBeUndefined() + expect(resolveActiveDependencyValue('triggerSiteId', values, action)).toBe('action-site') + expect( + resolveActiveDependencyValue( + 'serviceToken', + values, + buildCanonicalIndexForSurface(fields, true) + ) + ).toBe('stale-trigger-secret') + } + ) + it.concurrent('keeps the whole array on the action surface', () => { expect(getCanonicalSubBlocksForSurface(MIXED, false)).toBe(MIXED) }) diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 15b797fe72f..c31e8257653 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -144,17 +144,25 @@ export function buildCanonicalIndex(subBlocks: SubBlockConfig[]): CanonicalIndex * the whole reason execution has always been correct here. Every other caller resolves against the * block's FULL value map, so for them the scoping has to live in the index instead. * - * Only the trigger surface is filtered. The action surface keeps the whole array because a trigger - * member is already excluded by each caller's own trigger-mode filter, and because dropping it - * would also drop the `canonicalIdBySubBlockId` entry that lets a legacy alias still resolve - * through {@link resolveDependencyValue}. Mirrors `getSelectorContextSubBlocks`. + * The action surface preserves legacy trigger aliases unless their canonical key collides with + * a concrete action field: an inactive singleton must not replace that field's credentials. + * The trigger surface includes only trigger members. Mirrors `getSelectorContextSubBlocks`. */ export function getCanonicalSubBlocksForSurface( subBlocks: SubBlockConfig[], triggerSurface: boolean ): SubBlockConfig[] { - if (!triggerSurface) return subBlocks - return subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) + if (triggerSurface) return subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) + const actionIds = new Set( + subBlocks.filter((field) => !isTriggerModeSubBlock(field)).map((field) => field.id) + ) + const filtered = subBlocks.filter( + (field) => + !isTriggerModeSubBlock(field) || + !field.canonicalParamId || + !actionIds.has(field.canonicalParamId) + ) + return filtered.length === subBlocks.length ? subBlocks : filtered } /** {@link buildCanonicalIndex} over {@link getCanonicalSubBlocksForSurface}'s active set. */ diff --git a/apps/sim/testing/planetscale-app-fixture.ts b/apps/sim/testing/planetscale-app-fixture.ts new file mode 100644 index 00000000000..26038135862 --- /dev/null +++ b/apps/sim/testing/planetscale-app-fixture.ts @@ -0,0 +1,418 @@ +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import { createHmac } from 'node:crypto' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, resolve } from 'node:path' +import { db } from '@sim/db' +import { permissions, session, user, workflow, workspace } from '@sim/db/schema' +import { readTestDatabaseUrl, readTestRedisUrl } from '@sim/db/testing/test-infrastructure' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { serializeSignedCookie } from 'better-call' +import { eq } from 'drizzle-orm' + +/** Real Next/auth/database fixture; only fixed-origin PlanetScale requests are redirected. */ +export async function createPlanetScaleAppFixture() { + const root = resolve(process.cwd(), '../..') + const databaseUrl = readTestDatabaseUrl() + const redisUrl = readTestRedisUrl() + if (!redisUrl) throw new Error('PlanetScale app validation requires disposable Redis') + const directory = await mkdtemp(resolve(tmpdir(), 'sim-planetscale-app-')) + const userId = generateId() + const workspaceId = generateId() + const workflowId = generateId() + const authSecret = 'planetscale-fixture-auth-secret-is-not-a-real-credential' + const internalSecret = 'planetscale-fixture-internal-secret-is-not-a-real-credential' + const fixtureSecret = generateId() + const serviceTokenId = 'fixture-service-token-id' + const serviceToken = 'fixture-service-token-secret' + const signingSecret = 'fixture-generated-signing-secret' + const remoteHooks = new Map>() + const providerRequests: { method: string; path: string; status: number }[] = [] + const httpResults: { method: string; path: string; status: number; durationMs: number }[] = [] + let deleteStatus = 204 + let createWithoutSecret = false + let appUrl = '' + let child: ChildProcess | undefined + let realtimeChild: ChildProcess | undefined + let log = '' + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'PlanetScale Fixture', + email: `${userId}@planetscale.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'PlanetScale Fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + await db.insert(workflow).values({ + id: workflowId, + userId, + workspaceId, + name: 'PlanetScale Fixture', + createdAt: now, + updatedAt: now, + lastSynced: now, + }) + const token = generateId() + await db.insert(session).values({ + id: generateId(), + userId, + token, + expiresAt: new Date(Date.now() + 3_600_000), + createdAt: now, + updatedAt: now, + }) + const cookie = ( + await serializeSignedCookie('better-auth.session_token', token, authSecret) + ).split(';')[0] + + const providerServer = createServer(async (incoming, outgoing) => { + if (incoming.url === '/login') { + outgoing + .writeHead(302, { + 'set-cookie': `${cookie}; Path=/; HttpOnly; SameSite=Lax`, + location: `${appUrl}/workspace/${workspaceId}/w/${workflowId}`, + }) + .end() + return + } + if (incoming.headers['x-planetscale-fixture'] !== fixtureSecret) { + outgoing.writeHead(401).end() + return + } + const path = incoming.url?.split('?')[0] ?? '' + if (incoming.headers.authorization !== `${serviceTokenId}:${serviceToken}`) { + outgoing.writeHead(401).end() + providerRequests.push({ method: incoming.method ?? '', path, status: 401 }) + return + } + const reply = (status: number, body?: unknown) => { + providerRequests.push({ method: incoming.method ?? '', path, status }) + outgoing + .writeHead(status, { 'content-type': 'application/json' }) + .end(body === undefined ? undefined : JSON.stringify(body)) + } + if (incoming.method === 'GET' && path === '/v1/organizations/fixture-org/databases') { + reply(200, { + data: [{ id: 'fixture-db-id', name: 'fixture-db' }], + next_page: null, + current_page: 1, + per_page: 100, + total_count: 1, + total_pages: 1, + }) + return + } + if ( + incoming.method === 'GET' && + path === '/v1/organizations/fixture-org/databases/fixture-db' + ) { + reply(200, { + id: 'fixture-db-id', + name: 'fixture-db', + kind: 'vitess', + state: 'ready', + ready: true, + default_branch: 'main', + branches_count: 1, + deletion_protected: false, + require_approval_for_deploy: false, + created_at: '2023-10-25T16:54:12.879Z', + updated_at: '2023-10-25T16:54:39.820Z', + html_url: 'https://app.planetscale.com/fixture-org/fixture-db', + }) + return + } + const base = '/v1/organizations/fixture-org/databases/fixture-db/webhooks' + if (incoming.method === 'POST' && path === base) { + const chunks: Buffer[] = [] + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)) + const body: unknown = JSON.parse(Buffer.concat(chunks).toString()) + if ( + !isRecordLike(body) || + typeof body.url !== 'string' || + body.enabled !== true || + !Array.isArray(body.events) || + !body.events.length + ) { + reply(422, {}) + return + } + if (remoteHooks.size >= 5) { + reply(422, {}) + return + } + const id = generateId() + const hook = { + id, + url: body.url, + events: body.events, + enabled: true, + secret: createWithoutSecret ? '' : signingSecret, + } + remoteHooks.set(id, hook) + reply(201, hook) + return + } + const id = path.startsWith(`${base}/`) ? decodeURIComponent(path.slice(base.length + 1)) : '' + if (incoming.method === 'GET' && id) { + reply(remoteHooks.has(id) ? 200 : 404, remoteHooks.get(id)) + return + } + if (incoming.method === 'DELETE' && id) { + if (!remoteHooks.has(id)) { + reply(404) + return + } + if (deleteStatus === 204) remoteHooks.delete(id) + reply(deleteStatus) + return + } + reply(404, {}) + }) + const providerUrl = await listen(providerServer) + const appPort = await freePort() + const realtimePort = await freePort() + appUrl = `http://127.0.0.1:${appPort}` + const realtimeUrl = `http://127.0.0.1:${realtimePort}` + const preload = resolve(directory, 'provider-preload.mjs') + await writeFile( + preload, + ` +import net from 'node:net'; +import { syncBuiltinESMExports } from 'node:module'; +const nativeConnect = net.Socket.prototype.connect; +net.Socket.prototype.connect = function (...args) { + const normalized = Array.isArray(args[0]) ? args[0] : args; + const options = normalized[0]; + const host = typeof options === 'object' ? options.host ?? options.hostname ?? 'localhost' : typeof normalized[1] === 'string' ? normalized[1] : 'localhost'; + if (!(typeof options === 'object' && options.path) && !['localhost', '127.0.0.1', '::1'].includes(host)) throw new Error('Unexpected external socket in PlanetScale fixture'); + return nativeConnect.apply(this, args); +}; +syncBuiltinESMExports(); +const nativeFetch = globalThis.fetch; +const provider = ${JSON.stringify(providerUrl)}; +const allowed = new Set(${JSON.stringify([appUrl, realtimeUrl, providerUrl])}); +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.origin === 'https://api.planetscale.com' && url.pathname.startsWith('/v1/')) { + const headers = new Headers(request.headers); + headers.set('x-planetscale-fixture', ${JSON.stringify(fixtureSecret)}); + return nativeFetch(new Request(provider + url.pathname + url.search, { method: request.method, headers, body: ['GET','HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), signal: request.signal, redirect: 'error' })); + } + if (!allowed.has(url.origin)) throw new Error('Unexpected external request in PlanetScale fixture'); + return nativeFetch(request); +}; +` + ) + const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + TMPDIR: process.env.TMPDIR, + NODE_ENV: 'development', + DATABASE_URL: databaseUrl, + MIGRATION_DATABASE_URL: databaseUrl, + REDIS_URL: redisUrl, + BETTER_AUTH_SECRET: authSecret, + BETTER_AUTH_URL: appUrl, + INTERNAL_API_SECRET: internalSecret, + ENCRYPTION_KEY: '0'.repeat(64), + API_ENCRYPTION_KEY: '1'.repeat(64), + NEXT_PUBLIC_APP_URL: appUrl, + INTERNAL_API_BASE_URL: appUrl, + NEXT_PUBLIC_SOCKET_URL: realtimeUrl, + SOCKET_SERVER_URL: realtimeUrl, + BILLING_ENABLED: 'false', + DISABLE_AUTH: 'false', + DISABLE_TELEMETRY: 'true', + FORKING_ENABLED: 'true', + KNOWLEDGE_MEMBER_ACCESS: 'true', + ACCESS_CONTROL_ENABLED: 'true', + STORAGE_PROVIDER: 'local', + OCR_PROVIDER: 'local', + NEXT_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: `--max-old-space-size=8192 --import=${preload}`, + } + const capture = (process: ChildProcess) => { + process.stdout?.on('data', (chunk: Buffer) => { + log += chunk.toString() + }) + process.stderr?.on('data', (chunk: Buffer) => { + log += chunk.toString() + }) + } + const close = async () => { + await Promise.all([stopChild(child), stopChild(realtimeChild)]) + await writeFile( + resolve(directory, 'app.log'), + log + .replaceAll(cookie, '[REDACTED]') + .replaceAll(serviceToken, '[REDACTED]') + .replaceAll(signingSecret, '[REDACTED]') + ) + await new Promise((resolveClose) => { + providerServer.close(() => resolveClose()) + providerServer.closeAllConnections() + }) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + } + try { + const runtimeBuild = spawnSync('bun', ['--no-env-file', 'run', 'build:cli-runtime'], { + cwd: resolve(root, 'apps/sim'), + env: environment, + encoding: 'utf8', + }) + if (runtimeBuild.status !== 0) + throw new Error('Unable to build the existing CLI runtime for app validation') + realtimeChild = spawn('bun', ['--no-env-file', 'src/index.ts'], { + cwd: resolve(root, 'apps/realtime'), + env: { + ...environment, + NODE_OPTIONS: '', + PORT: String(realtimePort), + ALLOWED_ORIGINS: appUrl, + }, + stdio: 'pipe', + }) + capture(realtimeChild) + child = spawn( + 'node', + [ + resolve(root, 'node_modules/next/dist/bin/next'), + 'dev', + '--turbopack', + '--hostname', + '127.0.0.1', + '--port', + String(appPort), + ], + { cwd: resolve(root, 'apps/sim'), env: environment, stdio: 'pipe' } + ) + capture(child) + const deadline = Date.now() + 180_000 + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`PlanetScale app exited: ${log.slice(-4000)}`) + try { + const response = await fetch(`${appUrl}/api/health`, { signal: AbortSignal.timeout(2000) }) + if (response.status < 500) break + } catch {} + await sleep(500) + } + if (Date.now() >= deadline) throw new Error(`PlanetScale app did not boot: ${log.slice(-4000)}`) + } catch (error) { + await close() + throw error + } + + return { + appUrl, + providerUrl, + userId, + workspaceId, + workflowId, + cookie, + directory, + signingSecret, + serviceTokenId, + serviceToken, + providerRequests, + httpResults, + remoteHooks, + loginUrl: `${providerUrl}/login`, + setDeleteStatus(status: number) { + deleteStatus = status + }, + setCreateWithoutSecret(value: boolean) { + createWithoutSecret = value + }, + async request(path: string, method = 'GET', body?: unknown, authenticated = true) { + const start = performance.now() + const response = await fetch(`${appUrl}${path}`, { + method, + headers: { ...(authenticated ? { cookie } : {}), 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + httpResults.push({ + method, + path, + status: response.status, + durationMs: performance.now() - start, + }) + return response + }, + async deliver( + path: string, + payload: unknown, + headers: Record = {}, + valid = true + ) { + const raw = JSON.stringify(payload, null, 2) + const start = performance.now() + const response = await fetch(`${appUrl}/api/webhooks/trigger/${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-PlanetScale-Signature': valid + ? createHmac('sha256', signingSecret).update(raw).digest('hex') + : '0'.repeat(64), + ...headers, + }, + body: raw, + }) + httpResults.push({ + method: 'POST', + path: `/api/webhooks/trigger/${path}`, + status: response.status, + durationMs: performance.now() - start, + }) + return response + }, + async report(path: string, evidence: unknown) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify({ httpResults, providerRequests, evidence }, null, 2)) + }, + close, + } +} + +async function listen(server: Server): Promise { + await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Fixture did not bind loopback') + return `http://127.0.0.1:${address.port}` +} +async function freePort(): Promise { + const server = createServer() + const url = await listen(server) + await new Promise((resolveClose) => server.close(() => resolveClose())) + return Number(new URL(url).port) +} + +async function stopChild(child: ChildProcess | undefined): Promise { + if (!child || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + for (let attempt = 0; attempt < 50; attempt++) { + if (child.exitCode !== null || child.signalCode !== null) return + await sleep(100) + } + child.kill('SIGKILL') + if (child.exitCode === null && child.signalCode === null) + await new Promise((resolveExit) => child.once('exit', () => resolveExit())) +} diff --git a/apps/sim/triggers/planetscale/backup-failed.ts b/apps/sim/triggers/planetscale/backup-failed.ts new file mode 100644 index 00000000000..45d96f21a92 --- /dev/null +++ b/apps/sim/triggers/planetscale/backup-failed.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBackupFailedTrigger: TriggerConfig = { + id: 'planetscale_backup_failed', + name: 'PlanetScale Backup Failed', + provider: 'planetscale', + description: 'Run on PlanetScale backup failed events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_backup_failed', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Backup Failed'), + extraFields: buildPlanetScaleExtraFields('planetscale_backup_failed'), + }), + outputs: buildPlanetScaleOutputs('backup'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/backup-succeeded.ts b/apps/sim/triggers/planetscale/backup-succeeded.ts new file mode 100644 index 00000000000..7d16f70af22 --- /dev/null +++ b/apps/sim/triggers/planetscale/backup-succeeded.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBackupSucceededTrigger: TriggerConfig = { + id: 'planetscale_backup_succeeded', + name: 'PlanetScale Backup Succeeded', + provider: 'planetscale', + description: 'Run on PlanetScale backup succeeded events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_backup_succeeded', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Backup Succeeded'), + extraFields: buildPlanetScaleExtraFields('planetscale_backup_succeeded'), + }), + outputs: buildPlanetScaleOutputs('backup'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-anomaly.ts b/apps/sim/triggers/planetscale/branch-anomaly.ts new file mode 100644 index 00000000000..d43b8ca5388 --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-anomaly.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchAnomalyTrigger: TriggerConfig = { + id: 'planetscale_branch_anomaly', + name: 'PlanetScale Branch Anomaly', + provider: 'planetscale', + description: 'Run on PlanetScale branch anomaly events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_anomaly', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Branch Anomaly'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_anomaly'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-out-of-memory.ts b/apps/sim/triggers/planetscale/branch-out-of-memory.ts new file mode 100644 index 00000000000..f0ed1e0b849 --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-out-of-memory.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchOutOfMemoryTrigger: TriggerConfig = { + id: 'planetscale_branch_out_of_memory', + name: 'PlanetScale Branch Out of Memory', + provider: 'planetscale', + description: 'Run on PlanetScale branch out of memory events. Supports Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_out_of_memory', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Branch Out of Memory'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_out_of_memory'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-primary-promoted.ts b/apps/sim/triggers/planetscale/branch-primary-promoted.ts new file mode 100644 index 00000000000..665450c39df --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-primary-promoted.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchPrimaryPromotedTrigger: TriggerConfig = { + id: 'planetscale_branch_primary_promoted', + name: 'PlanetScale Branch Primary Promoted', + provider: 'planetscale', + description: 'Run on PlanetScale branch primary promoted events. Supports Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_primary_promoted', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Branch Primary Promoted'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_primary_promoted'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-ready.ts b/apps/sim/triggers/planetscale/branch-ready.ts new file mode 100644 index 00000000000..9f7f87e4f4e --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-ready.ts @@ -0,0 +1,30 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchReadyTrigger: TriggerConfig = { + id: 'planetscale_branch_ready', + name: 'PlanetScale Branch Ready', + provider: 'planetscale', + description: 'Run on PlanetScale branch ready events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_ready', + triggerOptions: planetscaleTriggerOptions, + includeDropdown: true, + setupInstructions: planetscaleSetupInstructions('Branch Ready'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_ready'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-sleeping.ts b/apps/sim/triggers/planetscale/branch-sleeping.ts new file mode 100644 index 00000000000..58ab37b4546 --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-sleeping.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchSleepingTrigger: TriggerConfig = { + id: 'planetscale_branch_sleeping', + name: 'PlanetScale Branch Sleeping', + provider: 'planetscale', + description: 'Run on PlanetScale branch sleeping events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_sleeping', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Branch Sleeping'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_sleeping'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/branch-start-maintenance.ts b/apps/sim/triggers/planetscale/branch-start-maintenance.ts new file mode 100644 index 00000000000..2a5267384b8 --- /dev/null +++ b/apps/sim/triggers/planetscale/branch-start-maintenance.ts @@ -0,0 +1,30 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleBranchStartMaintenanceTrigger: TriggerConfig = { + id: 'planetscale_branch_start_maintenance', + name: 'PlanetScale Branch Start Maintenance', + provider: 'planetscale', + description: + 'Run on PlanetScale branch start maintenance events. Supports Vitess, Neki, and Postgres.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_branch_start_maintenance', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Branch Start Maintenance'), + extraFields: buildPlanetScaleExtraFields('planetscale_branch_start_maintenance'), + }), + outputs: buildPlanetScaleOutputs('branch'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-closed.ts b/apps/sim/triggers/planetscale/deploy-request-closed.ts new file mode 100644 index 00000000000..bd5c50f92c2 --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-closed.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestClosedTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_closed', + name: 'PlanetScale Deploy Request Closed', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request closed events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_closed', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Closed'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_closed'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-errored.ts b/apps/sim/triggers/planetscale/deploy-request-errored.ts new file mode 100644 index 00000000000..d1dcb00daf8 --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-errored.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestErroredTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_errored', + name: 'PlanetScale Deploy Request Errored', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request errored events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_errored', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Errored'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_errored'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-in-progress.ts b/apps/sim/triggers/planetscale/deploy-request-in-progress.ts new file mode 100644 index 00000000000..34219cc1639 --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-in-progress.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestInProgressTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_in_progress', + name: 'PlanetScale Deploy Request In Progress', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request in progress events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_in_progress', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request In Progress'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_in_progress'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-opened.ts b/apps/sim/triggers/planetscale/deploy-request-opened.ts new file mode 100644 index 00000000000..9c386d3ce3e --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-opened.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestOpenedTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_opened', + name: 'PlanetScale Deploy Request Opened', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request opened events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_opened', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Opened'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_opened'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-pending-cutover.ts b/apps/sim/triggers/planetscale/deploy-request-pending-cutover.ts new file mode 100644 index 00000000000..ee4507f5550 --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-pending-cutover.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestPendingCutoverTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_pending_cutover', + name: 'PlanetScale Deploy Request Pending Cutover', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request pending cutover events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_pending_cutover', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Pending Cutover'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_pending_cutover'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-queued.ts b/apps/sim/triggers/planetscale/deploy-request-queued.ts new file mode 100644 index 00000000000..9c4952eecfe --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-queued.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestQueuedTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_queued', + name: 'PlanetScale Deploy Request Queued', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request queued events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_queued', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Queued'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_queued'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-reverted.ts b/apps/sim/triggers/planetscale/deploy-request-reverted.ts new file mode 100644 index 00000000000..04cc6b51302 --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-reverted.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestRevertedTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_reverted', + name: 'PlanetScale Deploy Request Reverted', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request reverted events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_reverted', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Reverted'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_reverted'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/deploy-request-schema-applied.ts b/apps/sim/triggers/planetscale/deploy-request-schema-applied.ts new file mode 100644 index 00000000000..8840170926f --- /dev/null +++ b/apps/sim/triggers/planetscale/deploy-request-schema-applied.ts @@ -0,0 +1,29 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleDeployRequestSchemaAppliedTrigger: TriggerConfig = { + id: 'planetscale_deploy_request_schema_applied', + name: 'PlanetScale Deploy Request Schema Applied', + provider: 'planetscale', + description: 'Run on PlanetScale deploy request schema applied events. Supports Vitess.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_deploy_request_schema_applied', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Deploy Request Schema Applied'), + extraFields: buildPlanetScaleExtraFields('planetscale_deploy_request_schema_applied'), + }), + outputs: buildPlanetScaleOutputs('deploy_request'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/planetscale/index.ts b/apps/sim/triggers/planetscale/index.ts new file mode 100644 index 00000000000..32c58a14092 --- /dev/null +++ b/apps/sim/triggers/planetscale/index.ts @@ -0,0 +1,17 @@ +export { planetscaleBackupFailedTrigger } from '@/triggers/planetscale/backup-failed' +export { planetscaleBackupSucceededTrigger } from '@/triggers/planetscale/backup-succeeded' +export { planetscaleBranchAnomalyTrigger } from '@/triggers/planetscale/branch-anomaly' +export { planetscaleBranchOutOfMemoryTrigger } from '@/triggers/planetscale/branch-out-of-memory' +export { planetscaleBranchPrimaryPromotedTrigger } from '@/triggers/planetscale/branch-primary-promoted' +export { planetscaleBranchReadyTrigger } from '@/triggers/planetscale/branch-ready' +export { planetscaleBranchSleepingTrigger } from '@/triggers/planetscale/branch-sleeping' +export { planetscaleBranchStartMaintenanceTrigger } from '@/triggers/planetscale/branch-start-maintenance' +export { planetscaleDeployRequestClosedTrigger } from '@/triggers/planetscale/deploy-request-closed' +export { planetscaleDeployRequestErroredTrigger } from '@/triggers/planetscale/deploy-request-errored' +export { planetscaleDeployRequestInProgressTrigger } from '@/triggers/planetscale/deploy-request-in-progress' +export { planetscaleDeployRequestOpenedTrigger } from '@/triggers/planetscale/deploy-request-opened' +export { planetscaleDeployRequestPendingCutoverTrigger } from '@/triggers/planetscale/deploy-request-pending-cutover' +export { planetscaleDeployRequestQueuedTrigger } from '@/triggers/planetscale/deploy-request-queued' +export { planetscaleDeployRequestRevertedTrigger } from '@/triggers/planetscale/deploy-request-reverted' +export { planetscaleDeployRequestSchemaAppliedTrigger } from '@/triggers/planetscale/deploy-request-schema-applied' +export { planetscaleWebhookTrigger } from '@/triggers/planetscale/webhook' diff --git a/apps/sim/triggers/planetscale/utils.ts b/apps/sim/triggers/planetscale/utils.ts new file mode 100644 index 00000000000..fce06382d99 --- /dev/null +++ b/apps/sim/triggers/planetscale/utils.ts @@ -0,0 +1,214 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** Documented production events; webhook.test is an authenticated reachability probe. */ +export const PLANETSCALE_EVENTS = [ + { id: 'branch.anomaly', label: 'Branch Anomaly (Vitess, Neki, Postgres)' }, + { id: 'branch.out_of_memory', label: 'Branch Out of Memory (Postgres)' }, + { id: 'branch.primary_promoted', label: 'Branch Primary Promoted (Postgres)' }, + { id: 'branch.ready', label: 'Branch Ready (Vitess, Neki, Postgres)' }, + { id: 'branch.sleeping', label: 'Branch Sleeping (Vitess, Neki, Postgres)' }, + { id: 'branch.start_maintenance', label: 'Branch Start Maintenance (Vitess, Neki, Postgres)' }, + { id: 'backup.failed', label: 'Backup Failed (Vitess, Neki, Postgres)' }, + { id: 'backup.succeeded', label: 'Backup Succeeded (Vitess, Neki, Postgres)' }, + { id: 'deploy_request.opened', label: 'Deploy Request Opened (Vitess)' }, + { id: 'deploy_request.queued', label: 'Deploy Request Queued (Vitess)' }, + { id: 'deploy_request.in_progress', label: 'Deploy Request In Progress (Vitess)' }, + { id: 'deploy_request.pending_cutover', label: 'Deploy Request Pending Cutover (Vitess)' }, + { id: 'deploy_request.schema_applied', label: 'Deploy Request Schema Applied (Vitess)' }, + { id: 'deploy_request.errored', label: 'Deploy Request Errored (Vitess)' }, + { id: 'deploy_request.reverted', label: 'Deploy Request Reverted (Vitess)' }, + { id: 'deploy_request.closed', label: 'Deploy Request Closed (Vitess)' }, + { id: 'cluster.storage', label: 'Cluster Storage (Postgres)' }, + { id: 'database.access_request', label: 'Database Access Request (Vitess, Neki, Postgres)' }, + { id: 'keyspace.storage', label: 'Keyspace Storage (Vitess)' }, +] as const + +export const planetscaleTriggerOptions = [ + ...PLANETSCALE_EVENTS.slice(0, 16).map((event) => ({ + id: `planetscale_${event.id.replace('.', '_')}`, + label: event.label, + })), + { id: 'planetscale_webhook', label: 'Selected Events' }, +] + +/** Validates the literal event selection used for registration and delivery filtering. */ +export function getPlanetScaleEvents(config: Record): string[] { + if (config.triggerId !== 'planetscale_webhook') { + const event = PLANETSCALE_EVENTS.slice(0, 16).find( + (item) => `planetscale_${item.id.replace('.', '_')}` === config.triggerId + ) + if (!event) throw new Error('Select a supported PlanetScale trigger.') + return [event.id] + } + const events = config.events + if ( + !Array.isArray(events) || + events.length === 0 || + events.some((value) => !PLANETSCALE_EVENTS.some((event) => event.id === value)) + ) { + throw new Error('Select at least one supported PlanetScale production event.') + } + return [...new Set(events)] +} + +export function planetscaleSetupInstructions(eventLabel: string): string { + return [ + 'Create an organization service token with read_database and write_database permissions for the database.', + 'Enter the Service Token ID, Service Token, and Organization, then select a database. Sim environment references are supported.', + `Deploy the workflow to register ${eventLabel} automatically. PlanetScale requires a publicly reachable HTTPS webhook URL.`, + 'Undeploy to remove the subscription. PlanetScale allows five webhooks per database; updating a deployed trigger temporarily needs another slot.', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +export function buildPlanetScaleExtraFields(triggerId: string): SubBlockConfig[] { + const condition = { field: 'selectedTriggerId', value: triggerId } + return [ + { + id: 'triggerServiceTokenId', + title: 'Service Token ID', + type: 'short-input', + canonicalParamId: 'serviceTokenId', + password: true, + paramVisibility: 'user-only', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'triggerServiceToken', + title: 'Service Token', + type: 'short-input', + canonicalParamId: 'serviceToken', + password: true, + paramVisibility: 'user-only', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'triggerOrganization', + title: 'Organization', + type: 'short-input', + canonicalParamId: 'organization', + placeholder: 'Organization slug', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'triggerDatabaseSelector', + title: 'Database', + type: 'file-selector', + canonicalParamId: 'database', + selectorKey: 'planetscale.databases', + dependsOn: ['triggerServiceTokenId', 'triggerServiceToken', 'triggerOrganization'], + placeholder: 'Select database', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'triggerManualDatabase', + title: 'Database Name', + type: 'short-input', + canonicalParamId: 'database', + placeholder: 'Database name', + required: true, + mode: 'trigger-advanced', + condition, + }, + ...(triggerId === 'planetscale_webhook' + ? [ + { + id: 'events', + title: 'Events', + type: 'dropdown' as const, + multiSelect: true, + options: [...PLANETSCALE_EVENTS], + defaultValue: ['branch.ready'], + required: true, + mode: 'trigger' as const, + condition, + }, + ] + : []), + ] +} + +const commonOutputs = { + event: { type: 'string', description: 'Documented PlanetScale event name' }, + timestamp: { type: 'number', description: 'Provider event time in Unix seconds' }, + organization: { type: 'string', description: 'Organization slug' }, + database: { type: 'string', description: 'Database name' }, + payload: { type: 'json', description: 'Complete original signed webhook payload' }, +} as const +const identityOutputs = { + id: { type: 'string', description: 'Resource ID' }, + name: { type: 'string', description: 'Resource name' }, + state: { type: 'string', description: 'Current resource state' }, + createdAt: { type: 'string', description: 'Creation time, or null when unavailable' }, + updatedAt: { type: 'string', description: 'Update time, or null when unavailable' }, +} as const + +export function buildPlanetScaleOutputs( + family: 'branch' | 'backup' | 'deploy_request' | 'webhook' +): Record { + if (family === 'webhook') + return { + ...commonOutputs, + resource: { type: 'json', description: 'Original event-specific resource' }, + } + if (family === 'branch') + return { + ...commonOutputs, + resource: { + ...identityOutputs, + ready: { type: 'boolean', description: 'Whether the branch is ready' }, + production: { type: 'boolean', description: 'Whether this is a production branch' }, + safeMigrations: { type: 'boolean', description: 'Whether safe migrations are enabled' }, + parentBranch: { type: 'string', description: 'Parent branch name, or null' }, + htmlUrl: { type: 'string', description: 'PlanetScale branch URL, or null' }, + }, + } + if (family === 'backup') + return { + ...commonOutputs, + resource: { + ...identityOutputs, + size: { type: 'number', description: 'Backup size in bytes, or null' }, + protected: { type: 'boolean', description: 'Whether the backup is protected' }, + startedAt: { type: 'string', description: 'Start time, or null' }, + completedAt: { type: 'string', description: 'Completion time, or null' }, + expiresAt: { type: 'string', description: 'Expiry time, or null' }, + branch: { + id: { type: 'string', description: 'Database branch ID, or null' }, + name: { type: 'string', description: 'Database branch name, or null' }, + }, + }, + } + return { + ...commonOutputs, + resource: { + id: identityOutputs.id, + state: identityOutputs.state, + createdAt: identityOutputs.createdAt, + updatedAt: identityOutputs.updatedAt, + number: { type: 'number', description: 'Database-scoped deploy request number' }, + branch: { type: 'string', description: 'Source branch name' }, + intoBranch: { type: 'string', description: 'Target branch name' }, + deploymentState: { type: 'string', description: 'Current deployment state' }, + approved: { type: 'boolean', description: 'Whether the request is approved' }, + numComments: { type: 'number', description: 'Number of comments' }, + notes: { type: 'string', description: 'Deploy request notes' }, + closedAt: { type: 'string', description: 'Close time, or null' }, + deployedAt: { type: 'string', description: 'Deployment time, or null' }, + htmlUrl: { type: 'string', description: 'PlanetScale deploy request URL' }, + }, + } +} diff --git a/apps/sim/triggers/planetscale/webhook.ts b/apps/sim/triggers/planetscale/webhook.ts new file mode 100644 index 00000000000..01103fa2f74 --- /dev/null +++ b/apps/sim/triggers/planetscale/webhook.ts @@ -0,0 +1,30 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPlanetScaleExtraFields, + buildPlanetScaleOutputs, + planetscaleSetupInstructions, + planetscaleTriggerOptions, +} from '@/triggers/planetscale/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const planetscaleWebhookTrigger: TriggerConfig = { + id: 'planetscale_webhook', + name: 'PlanetScale Selected Events', + provider: 'planetscale', + description: + 'Run on PlanetScale selected events. Supports the documented event-specific database engines.', + version: '1.0.0', + icon: PlanetScaleIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'planetscale_webhook', + triggerOptions: planetscaleTriggerOptions, + setupInstructions: planetscaleSetupInstructions('Selected Events'), + extraFields: buildPlanetScaleExtraFields('planetscale_webhook'), + }), + outputs: buildPlanetScaleOutputs('webhook'), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-PlanetScale-Signature': '...' }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index b791c5fb298..03205a87073 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -414,6 +414,25 @@ import { pagerdutyIncidentTriggeredTrigger, pagerdutyWebhookTrigger, } from '@/triggers/pagerduty' +import { + planetscaleBackupFailedTrigger, + planetscaleBackupSucceededTrigger, + planetscaleBranchAnomalyTrigger, + planetscaleBranchOutOfMemoryTrigger, + planetscaleBranchPrimaryPromotedTrigger, + planetscaleBranchReadyTrigger, + planetscaleBranchSleepingTrigger, + planetscaleBranchStartMaintenanceTrigger, + planetscaleDeployRequestClosedTrigger, + planetscaleDeployRequestErroredTrigger, + planetscaleDeployRequestInProgressTrigger, + planetscaleDeployRequestOpenedTrigger, + planetscaleDeployRequestPendingCutoverTrigger, + planetscaleDeployRequestQueuedTrigger, + planetscaleDeployRequestRevertedTrigger, + planetscaleDeployRequestSchemaAppliedTrigger, + planetscaleWebhookTrigger, +} from '@/triggers/planetscale' import { quickBooksAccountEventsTrigger, quickBooksBillEventsTrigger, @@ -853,6 +872,23 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { pagerduty_incident_escalated: pagerdutyIncidentEscalatedTrigger, pagerduty_incident_reassigned: pagerdutyIncidentReassignedTrigger, pagerduty_webhook: pagerdutyWebhookTrigger, + planetscale_branch_anomaly: planetscaleBranchAnomalyTrigger, + planetscale_branch_out_of_memory: planetscaleBranchOutOfMemoryTrigger, + planetscale_branch_primary_promoted: planetscaleBranchPrimaryPromotedTrigger, + planetscale_branch_ready: planetscaleBranchReadyTrigger, + planetscale_branch_sleeping: planetscaleBranchSleepingTrigger, + planetscale_branch_start_maintenance: planetscaleBranchStartMaintenanceTrigger, + planetscale_backup_failed: planetscaleBackupFailedTrigger, + planetscale_backup_succeeded: planetscaleBackupSucceededTrigger, + planetscale_deploy_request_opened: planetscaleDeployRequestOpenedTrigger, + planetscale_deploy_request_queued: planetscaleDeployRequestQueuedTrigger, + planetscale_deploy_request_in_progress: planetscaleDeployRequestInProgressTrigger, + planetscale_deploy_request_pending_cutover: planetscaleDeployRequestPendingCutoverTrigger, + planetscale_deploy_request_schema_applied: planetscaleDeployRequestSchemaAppliedTrigger, + planetscale_deploy_request_errored: planetscaleDeployRequestErroredTrigger, + planetscale_deploy_request_reverted: planetscaleDeployRequestRevertedTrigger, + planetscale_deploy_request_closed: planetscaleDeployRequestClosedTrigger, + planetscale_webhook: planetscaleWebhookTrigger, quickbooks_account_events: quickBooksAccountEventsTrigger, quickbooks_bill_events: quickBooksBillEventsTrigger, quickbooks_bill_payment_events: quickBooksBillPaymentEventsTrigger, diff --git a/packages/deployment-config/src/integrations.json b/packages/deployment-config/src/integrations.json index b3d946d81cc..6524663ae66 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-09-29", + "updatedAt": "2026-09-30", "integrations": [ { "type": "onepassword", @@ -18097,8 +18097,94 @@ } ], "operationCount": 15, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "planetscale_branch_ready", + "name": "PlanetScale Branch Ready", + "description": "Run on PlanetScale branch ready events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_branch_anomaly", + "name": "PlanetScale Branch Anomaly", + "description": "Run on PlanetScale branch anomaly events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_branch_out_of_memory", + "name": "PlanetScale Branch Out of Memory", + "description": "Run on PlanetScale branch out of memory events. Supports Postgres." + }, + { + "id": "planetscale_branch_primary_promoted", + "name": "PlanetScale Branch Primary Promoted", + "description": "Run on PlanetScale branch primary promoted events. Supports Postgres." + }, + { + "id": "planetscale_branch_sleeping", + "name": "PlanetScale Branch Sleeping", + "description": "Run on PlanetScale branch sleeping events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_branch_start_maintenance", + "name": "PlanetScale Branch Start Maintenance", + "description": "Run on PlanetScale branch start maintenance events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_backup_failed", + "name": "PlanetScale Backup Failed", + "description": "Run on PlanetScale backup failed events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_backup_succeeded", + "name": "PlanetScale Backup Succeeded", + "description": "Run on PlanetScale backup succeeded events. Supports Vitess, Neki, and Postgres." + }, + { + "id": "planetscale_deploy_request_opened", + "name": "PlanetScale Deploy Request Opened", + "description": "Run on PlanetScale deploy request opened events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_queued", + "name": "PlanetScale Deploy Request Queued", + "description": "Run on PlanetScale deploy request queued events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_in_progress", + "name": "PlanetScale Deploy Request In Progress", + "description": "Run on PlanetScale deploy request in progress events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_pending_cutover", + "name": "PlanetScale Deploy Request Pending Cutover", + "description": "Run on PlanetScale deploy request pending cutover events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_schema_applied", + "name": "PlanetScale Deploy Request Schema Applied", + "description": "Run on PlanetScale deploy request schema applied events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_errored", + "name": "PlanetScale Deploy Request Errored", + "description": "Run on PlanetScale deploy request errored events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_reverted", + "name": "PlanetScale Deploy Request Reverted", + "description": "Run on PlanetScale deploy request reverted events. Supports Vitess." + }, + { + "id": "planetscale_deploy_request_closed", + "name": "PlanetScale Deploy Request Closed", + "description": "Run on PlanetScale deploy request closed events. Supports Vitess." + }, + { + "id": "planetscale_webhook", + "name": "PlanetScale Selected Events", + "description": "Run on PlanetScale selected events. Supports the documented event-specific database engines." + } + ], + "triggerCount": 17, "authType": "api-key", "category": "tools", "integrationType": "databases", diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index e71fd3aa401..fdeb6823870 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -240,6 +240,7 @@ const TRIGGER_PROVIDER_DISPLAY_NAMES: Record = { 'microsoft-teams': 'Microsoft Teams', notion: 'Notion', outlook: 'Outlook', + planetscale: 'PlanetScale', resend: 'Resend', salesforce: 'Salesforce', servicenow: 'ServiceNow',