From b470a64a42683a94bfb11a42776afc9f4a42e7d6 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:59:07 -0500 Subject: [PATCH 01/13] feat(database): add a table for developer webhook endpoints --- .../0057_developer_webhook_endpoints.sql | 23 + .../migrations/meta/0057_snapshot.json | 5989 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 46 + 4 files changed, 6065 insertions(+) create mode 100644 packages/database/migrations/0057_developer_webhook_endpoints.sql create mode 100644 packages/database/migrations/meta/0057_snapshot.json diff --git a/packages/database/migrations/0057_developer_webhook_endpoints.sql b/packages/database/migrations/0057_developer_webhook_endpoints.sql new file mode 100644 index 00000000000..9f71ffc36c2 --- /dev/null +++ b/packages/database/migrations/0057_developer_webhook_endpoints.sql @@ -0,0 +1,23 @@ +CREATE TABLE `developer_webhook_endpoints` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `url` varchar(2048) NOT NULL, + `secretPrefix` varchar(12) NOT NULL, + `encryptedSecret` text NOT NULL, + `events` json NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `disabledReason` varchar(32), + `lastDeliveryAt` timestamp, + `lastDeliveryStatus` varchar(16), + `lastResponseStatus` int, + `lastErrorCode` varchar(32), + `consecutiveFailures` int NOT NULL DEFAULT 0, + `failingSince` timestamp, + `deletedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `developer_webhook_endpoints_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `developer_webhook_endpoints` ADD CONSTRAINT `developer_webhook_endpoints_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `dev_webhooks_app_list_idx` ON `developer_webhook_endpoints` (`appId`,`deletedAt`,`createdAt`,`id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0057_snapshot.json b/packages/database/migrations/meta/0057_snapshot.json new file mode 100644 index 00000000000..3cbdc2e354d --- /dev/null +++ b/packages/database/migrations/meta/0057_snapshot.json @@ -0,0 +1,5989 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ae334805-3e34-4bb8-b485-75d29b3bde1e", + "prevId": "2773f4a4-e037-4a2a-9537-3e419186570c", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + }, + "owner_idx": { + "name": "owner_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_created_idx": { + "name": "account_created_idx", + "columns": ["accountId", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_transcripts": { + "name": "developer_transcripts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resultKey": { + "name": "resultKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBytes": { + "name": "sourceBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "durationSeconds": { + "name": "durationSeconds", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedLanguage": { + "name": "requestedLanguage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "mediumtext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wordCount": { + "name": "wordCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservedMicroCredits": { + "name": "reservedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billedMicroCredits": { + "name": "billedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queuedAt": { + "name": "queuedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_transcripts_app_list_idx": { + "name": "dev_transcripts_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + }, + "dev_transcripts_app_user_idx": { + "name": "dev_transcripts_app_user_idx", + "columns": ["appId", "externalUserId", "createdAt"], + "isUnique": false + }, + "dev_transcripts_app_status_idx": { + "name": "dev_transcripts_app_status_idx", + "columns": ["appId", "status", "createdAt"], + "isUnique": false + }, + "dev_transcripts_status_updated_idx": { + "name": "dev_transcripts_status_updated_idx", + "columns": ["status", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_transcripts_appId_developer_apps_id_fk": { + "name": "developer_transcripts_appId_developer_apps_id_fk", + "tableFrom": "developer_transcripts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_transcripts_id": { + "name": "developer_transcripts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "playbackId": { + "name": "playbackId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxResolutionTier": { + "name": "maxResolutionTier", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1080p'" + }, + "renditions": { + "name": "renditions", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storedBytes": { + "name": "storedBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorReason": { + "name": "errorReason", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing": { + "name": "processing", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesDeletedAt": { + "name": "filesDeletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + }, + "playback_id_idx": { + "name": "playback_id_idx", + "columns": ["playbackId"], + "isUnique": true + }, + "files_pending_idx": { + "name": "files_pending_idx", + "columns": ["filesDeletedAt", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_webhook_endpoints": { + "name": "developer_webhook_endpoints", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretPrefix": { + "name": "secretPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedSecret": { + "name": "encryptedSecret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "disabledReason": { + "name": "disabledReason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastDeliveryAt": { + "name": "lastDeliveryAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastDeliveryStatus": { + "name": "lastDeliveryStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastResponseStatus": { + "name": "lastResponseStatus", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastErrorCode": { + "name": "lastErrorCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutiveFailures": { + "name": "consecutiveFailures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failingSince": { + "name": "failingSince", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_webhooks_app_list_idx": { + "name": "dev_webhooks_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_webhook_endpoints_appId_developer_apps_id_fk": { + "name": "developer_webhook_endpoints_appId_developer_apps_id_fk", + "tableFrom": "developer_webhook_endpoints", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_webhook_endpoints_id": { + "name": "developer_webhook_endpoints_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "directory_users": { + "name": "directory_users", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "directoryId": { + "name": "directoryId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "directory_users_organization_id_idx": { + "name": "directory_users_organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "directory_users_user_org_idx": { + "name": "directory_users_user_org_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "directory_users_id": { + "name": "directory_users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_imports": { + "name": "loom_migration_imports", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_imports_request_idx": { + "name": "loom_migration_imports_request_idx", + "columns": ["requestId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_imports_videoId": { + "name": "loom_migration_imports_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_requests": { + "name": "loom_migration_requests", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedByUserId": { + "name": "requestedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspaceName": { + "name": "workspaceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerNote": { + "name": "customerNote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerReply": { + "name": "customerReply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "capMessage": { + "name": "capMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expectedVideoCount": { + "name": "expectedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedVideoCount": { + "name": "importedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queuedVideoCount": { + "name": "queuedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportCount": { + "name": "activeImportCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportLeaseToken": { + "name": "activeImportLeaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeImportLeaseUntil": { + "name": "activeImportLeaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorUserId": { + "name": "lastOperatorUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorAt": { + "name": "lastOperatorAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_active_org_idx": { + "name": "loom_migration_active_org_idx", + "columns": ["activeOrganizationId"], + "isUnique": true + }, + "loom_migration_org_created_idx": { + "name": "loom_migration_org_created_idx", + "columns": ["organizationId", "createdAt"], + "isUnique": false + }, + "loom_migration_status_created_idx": { + "name": "loom_migration_status_created_idx", + "columns": ["status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_requests_id": { + "name": "loom_migration_requests_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": ["nextAttemptAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": ["userId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": ["clientId"], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": ["activatedAt", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": ["windowId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": ["accessHash"], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": ["refreshHash"], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": ["familyId"], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": ["clientId", "revokedAt", "refreshExpiresAt"], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": ["refreshExpiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_claims": { + "name": "mobile_push_claims", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_claims_id": { + "name": "mobile_push_claims_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_tokens": { + "name": "mobile_push_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionHash": { + "name": "sessionHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": ["token"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "session_hash_idx": { + "name": "session_hash_idx", + "columns": ["sessionHash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_tokens_id": { + "name": "mobile_push_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_directory_sync": { + "name": "organization_directory_sync", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedAt": { + "name": "syncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseExpiresAt": { + "name": "leaseExpiresAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastError": { + "name": "lastError", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heldRemovals": { + "name": "heldRemovals", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removalsApprovedAt": { + "name": "removalsApprovedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_directory_sync_organizationId": { + "name": "organization_directory_sync_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": ["workosOrganizationId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": ["organizationId"], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": ["integrationId", "`objectKey`(191)"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": ["state", "next_retry_at", "video_id"], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": ["state", "lease_expires_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + }, + "started_at_idx": { + "name": "started_at_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": ["videoId", "email"], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": ["ownerId", "updatedAt", "id"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_owner_folder_effective_idx": { + "name": "org_owner_folder_effective_idx", + "columns": ["orgId", "ownerId", "folderId", "effectiveCreatedAt"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": ["isScreenshot", "transcriptionStatus", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9f14cd20c63..136dab1d66f 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1794461929204, "tag": "0055_developer_transcripts", "breakpoints": true + }, + { + "idx": 57, + "version": "5", + "when": 1794527001285, + "tag": "0057_developer_webhook_endpoints", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index a454936392f..4ad06faa0b0 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -2150,6 +2150,42 @@ export const developerDailyStorageSnapshots = mysqlTable( (table) => [unique("app_date_unique").on(table.appId, table.snapshotDate)], ); +export const developerWebhookEndpoints = mysqlTable( + "developer_webhook_endpoints", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + url: varchar("url", { length: 2048 }).notNull(), + secretPrefix: varchar("secretPrefix", { length: 12 }).notNull(), + encryptedSecret: encryptedText("encryptedSecret").notNull(), + events: json("events").notNull().$type(), + enabled: boolean("enabled").notNull().default(true), + disabledReason: varchar("disabledReason", { length: 32 }), + lastDeliveryAt: timestamp("lastDeliveryAt"), + lastDeliveryStatus: varchar("lastDeliveryStatus", { + length: 16, + enum: ["succeeded", "failed"], + }), + lastResponseStatus: int("lastResponseStatus"), + lastErrorCode: varchar("lastErrorCode", { length: 32 }), + consecutiveFailures: int("consecutiveFailures").notNull().default(0), + failingSince: timestamp("failingSince"), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [ + index("dev_webhooks_app_list_idx").on( + table.appId, + table.deletedAt, + table.createdAt, + table.id, + ), + ], +); + export const developerAppsRelations = relations( developerApps, ({ one, many }) => ({ @@ -2242,3 +2278,13 @@ export const developerDailyStorageSnapshotsRelations = relations( }), }), ); + +export const developerWebhookEndpointsRelations = relations( + developerWebhookEndpoints, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerWebhookEndpoints.appId], + references: [developerApps.id], + }), + }), +); From d76e91de51f16e8ae025c06e4637ac649bb8320f Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:59:07 -0500 Subject: [PATCH 02/13] fix(web): refuse literal private addresses and more reserved ranges in public-only requests Node skips a custom lookup when the host is already an IP address, so requestPublicOnly on its own would have connected to a literal private address. Every caller checked the URL first, so nothing reached it, but the request now refuses the literal itself. It also sends a text body, which outbound webhooks need. The address list gains the documentation ranges, 6to4, Teredo, the discard prefix and local-use NAT64. --- .../developer-transcribe-download.test.ts | 21 ++++++++++++++++++- apps/web/lib/s3-endpoint-policy.ts | 13 +++++++++++- apps/web/lib/storage-fetch.ts | 17 ++++++++++++--- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/developer-transcribe-download.test.ts b/apps/web/__tests__/unit/developer-transcribe-download.test.ts index 09b2fc91634..92da96f100a 100644 --- a/apps/web/__tests__/unit/developer-transcribe-download.test.ts +++ b/apps/web/__tests__/unit/developer-transcribe-download.test.ts @@ -80,6 +80,25 @@ vi.mock("@/lib/developer-transcripts", async (importOriginal) => ({ TRANSCRIPT_URL_DOWNLOAD_TIMEOUT_MS: 2_000, })); +// requestPublicOnly refuses a loopback address, by name or as a literal, so +// the local server is reached through a name this lookup pins to it. +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + const publicOnlyLookup: typeof actual.publicOnlyLookup = ( + _hostname, + options, + callback, + ) => { + if (typeof options === "object" && options.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + callback(null, "127.0.0.1", 4); + }; + return { ...actual, publicOnlyLookup }; +}); + vi.mock("@/lib/public-media-fetch", async (importOriginal) => { const actual = await importOriginal(); @@ -258,7 +277,7 @@ const serve = async (options: { await new Promise((resolve) => state.server.listen(0, "127.0.0.1", resolve), ); - context.origin = `http://127.0.0.1:${(state.server.address() as AddressInfo).port}`; + context.origin = `http://media.test:${(state.server.address() as AddressInfo).port}`; origin = state; return state; }; diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts index a9c5b383066..384f66d77e9 100644 --- a/apps/web/lib/s3-endpoint-policy.ts +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -15,8 +15,12 @@ for (const [network, prefix] of [ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4], ] as const) { @@ -26,10 +30,17 @@ for (const [network, prefix] of [ // BlockList already applies the IPv4 rules to IPv4-mapped addresses // (::ffff:a.b.c.d and ::ffff:7f00:1). Adding ::ffff:0:0/96 here would also // match every plain IPv4 address, so only the IPv4-compatible (::/96) and NAT64 -// (64:ff9b::/96) forms are blocked outright. +// (64:ff9b::/96) forms are blocked outright. 6to4 (2002::/16) and Teredo +// (2001::/32) carry an IPv4 address inside them, so a relay could deliver the +// request to a private IPv4 host that the IPv4 rules never saw. for (const [network, prefix] of [ ["::", 96], ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 32], + ["2001:db8::", 32], + ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], diff --git a/apps/web/lib/storage-fetch.ts b/apps/web/lib/storage-fetch.ts index e8228c019d6..5668496c35f 100644 --- a/apps/web/lib/storage-fetch.ts +++ b/apps/web/lib/storage-fetch.ts @@ -1,8 +1,10 @@ import { Agent as HttpAgent, request as httpRequest } from "node:http"; import { Agent as HttpsAgent, request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; import { Readable } from "node:stream"; import { isBlockedS3Endpoint, + isPrivateAddress, publicOnlyLookup, shouldBlockPrivateS3Endpoints, } from "@/lib/s3-endpoint-policy"; @@ -33,13 +35,15 @@ const httpsAgent = new HttpsAgent({ }); // The connection resolves through publicOnlyLookup, so the address it dials is the address that was checked. +// Node skips the lookup when the host is already an IP address, so a literal is checked here instead. export function requestPublicOnly( url: string, init: RequestInit = {}, ): Promise { - if (init.body !== undefined && init.body !== null) + const body = init.body ?? undefined; + if (body !== undefined && typeof body !== "string") return Promise.reject( - new Error("Storage requests do not send a request body"), + new Error("Storage requests only send a text request body"), ); const target = new URL(url); const isHttps = target.protocol === "https:"; @@ -47,6 +51,13 @@ export function requestPublicOnly( return Promise.reject( new StorageFetchRefusedError("Storage URLs must use http or https"), ); + const literal = target.hostname.replace(/^\[|\]$/g, ""); + if (isIP(literal) && isPrivateAddress(literal)) + return Promise.reject( + new StorageFetchRefusedError( + `Refused to connect to private address ${literal}`, + ), + ); const method = init.method ?? "GET"; return new Promise((resolve, reject) => { const request = (isHttps ? httpsRequest : httpRequest)( @@ -92,7 +103,7 @@ export function requestPublicOnly( }, ); request.on("error", reject); - request.end(); + request.end(body); }); } From 2e04a7b3c5d6c33042d216d51e03a29e5e6fb97a Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:59:07 -0500 Subject: [PATCH 03/13] feat(web): send signed webhooks when a developer video is ready or has failed --- ...deliver-developer-webhook-workflow.test.ts | 95 ++++ .../unit/developer-webhook-delivery.test.ts | 459 ++++++++++++++++++ .../unit/developer-webhook-dispatch.test.ts | 408 ++++++++++++++++ .../unit/developer-webhook-fake-db.ts | 100 ++++ .../unit/developer-webhook-guard.test.ts | 283 +++++++++++ .../unit/developer-webhook-signature.test.ts | 122 +++++ .../unit/developer-webhooks-api.test.ts | 419 ++++++++++++++++ .../unit/developer-webhooks-auth.test.ts | 146 ++++++ .../app/api/developer/v1/[...route]/route.ts | 2 + .../api/developer/v1/[...route]/webhooks.ts | 143 ++++++ apps/web/lib/developer-video-processing.ts | 2 + apps/web/lib/developer-webhook-delivery.ts | 294 +++++++++++ apps/web/lib/developer-webhook-dispatch.ts | 116 +++++ apps/web/lib/developer-webhook-events.ts | 51 ++ apps/web/lib/developer-webhook-signature.ts | 74 +++ apps/web/lib/developer-webhooks.ts | 294 +++++++++++ .../workflows/deliver-developer-webhook.ts | 41 ++ 17 files changed, 3049 insertions(+) create mode 100644 apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhook-delivery.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhook-dispatch.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhook-fake-db.ts create mode 100644 apps/web/__tests__/unit/developer-webhook-guard.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhook-signature.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhooks-api.test.ts create mode 100644 apps/web/__tests__/unit/developer-webhooks-auth.test.ts create mode 100644 apps/web/app/api/developer/v1/[...route]/webhooks.ts create mode 100644 apps/web/lib/developer-webhook-delivery.ts create mode 100644 apps/web/lib/developer-webhook-dispatch.ts create mode 100644 apps/web/lib/developer-webhook-events.ts create mode 100644 apps/web/lib/developer-webhook-signature.ts create mode 100644 apps/web/lib/developer-webhooks.ts create mode 100644 apps/web/workflows/deliver-developer-webhook.ts diff --git a/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts b/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts new file mode 100644 index 00000000000..ef2ea05ab6c --- /dev/null +++ b/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sleep: vi.fn(), + attempt: vi.fn(), +})); + +vi.mock("workflow", () => ({ sleep: mocks.sleep })); +vi.mock("@/lib/developer-webhook-delivery", () => ({ + deliverDeveloperWebhookAttempt: mocks.attempt, +})); + +const { deliverDeveloperWebhookWorkflow } = await import( + "@/workflows/deliver-developer-webhook" +); + +const job = { + endpointId: "wh-1", + eventId: "evt_1", + eventType: "video.ready", + body: "{}", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sleep.mockResolvedValue(undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +describe("deliver developer webhook workflow", () => { + it("stops after the first attempt when it is delivered", async () => { + mocks.attempt.mockResolvedValue("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 1, + }); + expect(mocks.attempt).toHaveBeenCalledWith(job); + expect(mocks.sleep).not.toHaveBeenCalled(); + }); + + it("retries a failing endpoint on the schedule and then delivers", async () => { + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 3, + }); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual([ + "60s", + "300s", + ]); + }); + + it("gives up after eight attempts spread over about a day", async () => { + mocks.attempt.mockResolvedValue("retry"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "gave_up", + attempts: 8, + }); + expect(mocks.attempt).toHaveBeenCalledTimes(8); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual([ + "60s", + "300s", + "1800s", + "7200s", + "18000s", + "28800s", + "28800s", + ]); + }); + + it("stops retrying once the endpoint is deleted or disabled", async () => { + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("dropped"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "dropped", + attempts: 2, + }); + expect(mocks.sleep).toHaveBeenCalledTimes(1); + }); + + it("keeps an attempt that could not run on the same schedule", async () => { + mocks.attempt + .mockRejectedValueOnce(new Error("database is away")) + .mockResolvedValueOnce("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 2, + }); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual(["60s"]); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-delivery.test.ts b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts new file mode 100644 index 00000000000..a14f9c68340 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts @@ -0,0 +1,459 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + seed: ( + table: object, + row: Record, + ) => Record; + reset: () => void; + }, + appDeleted: false, +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createWebhookFakeDatabase, webhookEndpointDefaults } = await import( + "./developer-webhook-fake-db" + ); + const fake = createWebhookFakeDatabase( + new Map([[schema.developerWebhookEndpoints, webhookEndpointDefaults]]), + { joined: () => !context.appDeleted }, + ); + context.fake = fake; + return { db: () => fake.db }; +}); +vi.mock("@cap/database/crypto", () => ({ + encrypt: async (text: string) => `sealed:${text}`, + decrypt: async (text: string) => text.replace(/^sealed:/, ""), +})); +vi.mock("@cap/env", () => ({ + NODE_ENV: "test", + buildEnv: {}, + serverEnv: () => ({}), +})); +// The production transport refuses loopback, so the local server is reached +// through a name this lookup pins to it. +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + const publicOnlyLookup: typeof actual.publicOnlyLookup = ( + _hostname, + options, + callback, + ) => { + if (typeof options === "object" && options.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + callback(null, "127.0.0.1", 4); + }; + return { ...actual, publicOnlyLookup }; +}); + +const { developerWebhookEndpoints } = await import("@cap/database/schema"); +const { + deliverDeveloperWebhookAttempt, + recordWebhookDelivery, + sendDeveloperWebhook, +} = await import("@/lib/developer-webhook-delivery"); +const { + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_DISABLED_BY_FAILURES, +} = await import("@/lib/developer-webhook-events"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); + +const SECRET = "cws_0123456789abcdefghjkmnpqrstvwx"; + +function fake() { + if (!context.fake) throw new Error("no database"); + return context.fake; +} + +function seedEndpoint(overrides: Record = {}) { + return fake().seed(developerWebhookEndpoints, { + id: "wh-1", + appId: "app-1", + url: "https://hooks.example.com/cap", + secretPrefix: SECRET.slice(0, 12), + encryptedSecret: `sealed:${SECRET}`, + ...overrides, + }); +} + +const endpoint = () => { + const [row] = fake().rows(developerWebhookEndpoints); + if (!row) throw new Error("no endpoint"); + return row; +}; + +const job = { + endpointId: "wh-1", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready", data: {} }), +}; + +const answers = (status: number) => ({ + isBlocked: async () => false, + request: vi.fn(async () => new Response("ignored", { status })), +}); + +beforeEach(() => { + fake().reset(); + context.appDeleted = false; +}); + +describe("a customer endpoint over real HTTP", () => { + let server: Server; + let origin: string; + const received: { + headers: IncomingMessage["headers"]; + body: string; + method?: string; + }[] = []; + const held: IncomingMessage[] = []; + + beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + received.push({ + headers: req.headers, + method: req.method, + body: Buffer.concat(chunks).toString("utf8"), + }); + if (req.url === "/hang") { + held.push(req); + return; + } + if (req.url === "/big") { + res.writeHead(200); + res.write("x".repeat(64 * 1024)); + held.push(req); + return; + } + if (req.url === "/moved") { + res.writeHead(307, { location: "/ok" }); + res.end(); + return; + } + res.writeHead(req.url === "/down" ? 503 : 200); + res.end("thanks"); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + for (const req of held) req.socket.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + received.length = 0; + }); + + const send = (path: string, timeoutMs?: number) => + sendDeveloperWebhook( + { + url: `${origin}${path}`, + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + { allowPrivate: true, timeoutMs }, + ); + + it("posts the exact body with a signature the receiver can verify", async () => { + expect(await send("/ok")).toMatchObject({ ok: true, responseStatus: 200 }); + const [request] = received; + expect(request?.method).toBe("POST"); + expect(request?.body).toBe(job.body); + expect(request?.headers["content-type"]).toBe("application/json"); + expect(request?.headers["cap-event-id"]).toBe("evt_1"); + expect(request?.headers["cap-event-type"]).toBe("video.ready"); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: request?.body ?? "", + header: String(request?.headers["cap-signature"]), + }), + ).toBe(true); + }); + + it("reports a server error as a failed delivery", async () => { + expect(await send("/down")).toMatchObject({ + ok: false, + errorCode: "http_error", + responseStatus: 503, + }); + }); + + it("does not follow a redirect", async () => { + expect(await send("/moved")).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 307, + }); + expect(received).toHaveLength(1); + }); + + it("gives up on an endpoint that never answers", async () => { + const started = Date.now(); + expect(await send("/hang", 150)).toMatchObject({ + ok: false, + errorCode: "timeout", + responseStatus: null, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("does not wait for or read a response body that never ends", async () => { + const started = Date.now(); + expect(await send("/big", 5_000)).toMatchObject({ + ok: true, + responseStatus: 200, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + describe("through the transport a production build uses", () => { + const sendPinned = (path: string, timeoutMs?: number) => + sendDeveloperWebhook( + { + url: `${origin.replace("127.0.0.1", "hooks.test")}${path}`, + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + { allowPrivate: true, request: requestPublicOnly, timeoutMs }, + ); + + it("posts the exact body with its length and a verifiable signature", async () => { + expect(await sendPinned("/ok")).toMatchObject({ + ok: true, + responseStatus: 200, + }); + const [request] = received; + expect(request?.method).toBe("POST"); + expect(request?.body).toBe(job.body); + expect(request?.headers["content-length"]).toBe( + String(Buffer.byteLength(job.body)), + ); + expect(request?.headers.host).toContain("hooks.test"); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: request?.body ?? "", + header: String(request?.headers["cap-signature"]), + }), + ).toBe(true); + }); + + it("reports a server error and does not follow a redirect", async () => { + expect(await sendPinned("/down")).toMatchObject({ + ok: false, + errorCode: "http_error", + responseStatus: 503, + }); + expect(await sendPinned("/moved")).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 307, + }); + expect(received).toHaveLength(2); + }); + + it("gives up on an endpoint that never answers", async () => { + const started = Date.now(); + expect(await sendPinned("/hang", 150)).toMatchObject({ + ok: false, + errorCode: "timeout", + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("does not wait for or read a response body that never ends", async () => { + const started = Date.now(); + expect(await sendPinned("/big", 5_000)).toMatchObject({ + ok: true, + responseStatus: 200, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + }); + + it("reports a closed port as a failed connection", async () => { + const result = await sendDeveloperWebhook( + { ...job, url: "http://127.0.0.1:1/cap", secret: SECRET }, + { allowPrivate: true }, + ); + expect(result).toMatchObject({ + ok: false, + errorCode: "connection_failed", + }); + }); +}); + +describe("one delivery attempt", () => { + it("delivers, records the result and clears the failure streak", async () => { + seedEndpoint({ + consecutiveFailures: 4, + failingSince: new Date("2026-11-10T00:00:00Z"), + }); + const guards = answers(200); + + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("delivered"); + + expect(guards.request).toHaveBeenCalledTimes(1); + expect(endpoint()).toMatchObject({ + lastDeliveryStatus: "succeeded", + lastResponseStatus: 200, + lastErrorCode: null, + consecutiveFailures: 0, + failingSince: null, + enabled: true, + }); + expect(endpoint().lastDeliveryAt).toBeInstanceOf(Date); + }); + + it("asks for a retry and counts the failure", async () => { + seedEndpoint(); + expect(await deliverDeveloperWebhookAttempt(job, answers(500))).toBe( + "retry", + ); + expect(endpoint()).toMatchObject({ + lastDeliveryStatus: "failed", + lastResponseStatus: 500, + lastErrorCode: "http_error", + consecutiveFailures: 1, + enabled: true, + }); + expect(endpoint().failingSince).toBeInstanceOf(Date); + }); + + it("keeps the start of a failure streak across later failures", async () => { + const failingSince = new Date("2026-11-10T00:00:00Z"); + seedEndpoint({ consecutiveFailures: 2, failingSince }); + await deliverDeveloperWebhookAttempt(job, answers(500)); + expect(endpoint()).toMatchObject({ consecutiveFailures: 3, failingSince }); + }); + + it("leaves the endpoint's updatedAt alone", async () => { + const seeded = seedEndpoint(); + const before = seeded.updatedAt; + await deliverDeveloperWebhookAttempt(job, answers(500)); + await deliverDeveloperWebhookAttempt(job, answers(200)); + expect(endpoint().updatedAt).toBe(before); + }); + + it.each([ + ["was deleted", { deletedAt: new Date() }], + ["was disabled", { enabled: false }], + ["no longer wants the event", { events: ["video.errored"] }], + ])("drops the delivery when the endpoint %s", async (_label, overrides) => { + seedEndpoint(overrides); + const guards = answers(200); + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + expect(guards.request).not.toHaveBeenCalled(); + expect(endpoint().lastDeliveryAt).toBeNull(); + }); + + it("drops the delivery when the endpoint is gone or its app was deleted", async () => { + const guards = answers(200); + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + seedEndpoint(); + context.appDeleted = true; + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + expect(guards.request).not.toHaveBeenCalled(); + }); +}); + +describe("disabling an endpoint that keeps failing", () => { + const failed = { + ok: false as const, + responseStatus: 500, + errorCode: "http_error" as const, + durationMs: 5, + }; + const now = new Date("2026-11-20T12:00:00Z"); + const longAgo = new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS - 1_000); + + it("disables after many failures over several days", async () => { + seedEndpoint({ + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES - 1, + failingSince: longAgo, + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ + enabled: false, + disabledReason: WEBHOOK_DISABLED_BY_FAILURES, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES, + }); + }); + + it("keeps an endpoint with many failures in a short time", async () => { + seedEndpoint({ + consecutiveFailures: 500, + failingSince: new Date(now.getTime() - 60 * 60 * 1000), + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ enabled: true, disabledReason: null }); + }); + + it("keeps an endpoint with a few failures over a long time", async () => { + seedEndpoint({ consecutiveFailures: 3, failingSince: longAgo }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ enabled: true, consecutiveFailures: 4 }); + }); + + it("never counts a test event towards the streak", async () => { + seedEndpoint({ + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + failingSince: longAgo, + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: false, + now, + }); + expect(endpoint()).toMatchObject({ + enabled: true, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + lastDeliveryStatus: "failed", + lastErrorCode: "http_error", + }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts new file mode 100644 index 00000000000..8b3f28100dc --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts @@ -0,0 +1,408 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type DeveloperVideoStore, + developerVideo, + processingState, +} from "../fixtures/developer-video"; + +type Endpoint = { + id: string; + appId: string; + events: string[]; + enabled: boolean; + deletedAt: Date | null; +}; + +const mocks = vi.hoisted(() => ({ + store: { video: null, appDeleted: false, updates: [] } as DeveloperVideoStore, + endpoints: [] as Endpoint[], + endpointQueries: 0, + failEndpointQuery: false, + hangEndpointQuery: false, + start: vi.fn(), + head: vi.fn(), + readText: vi.fn(), +})); + +vi.mock("@cap/database", async () => { + const { createDeveloperVideoDatabase } = await import( + "../fixtures/developer-video" + ); + const schema = await import("@cap/database/schema"); + const videos = await createDeveloperVideoDatabase(mocks.store); + const endpointRows = async () => { + mocks.endpointQueries += 1; + if (mocks.hangEndpointQuery) await new Promise(() => {}); + if (mocks.failEndpointQuery) throw new Error("database is away"); + const appId = mocks.store.video?.appId; + return mocks.endpoints.filter( + (endpoint) => + endpoint.appId === appId && endpoint.enabled && !endpoint.deletedAt, + ); + }; + // The real row lock makes two writers to one video take turns. The video + // fake has no lock, so transactions are queued here to stand in for it. + let turn: Promise = Promise.resolve(); + const database = { + ...videos, + select: (fields?: Record) => ({ + from: (table: object) => + table === schema.developerWebhookEndpoints + ? { where: endpointRows } + : videos.select(fields).from(), + }), + transaction: (run: (tx: unknown) => Promise) => { + const next = turn.then(() => run(database)); + turn = next.catch(() => undefined); + return next; + }, + }; + return { db: () => database }; +}); +vi.mock("@cap/env", () => ({ + NODE_ENV: "test", + serverEnv: () => ({ + WEB_URL: "https://web.example", + INFRA_PLAYBACK_URL: "https://play.example", + MEDIA_SERVER_WEBHOOK_URL: undefined, + }), +})); +vi.mock("workflow/api", () => ({ start: mocks.start })); +vi.mock("@/workflows/deliver-developer-webhook", () => ({ + deliverDeveloperWebhookWorkflow: "deliver-developer-webhook", +})); +vi.mock("@/lib/developer-video-storage", () => ({ + headDeveloperVideoObject: mocks.head, + readDeveloperVideoText: mocks.readText, + deleteDeveloperVideoObjects: vi.fn(), + developerVideoPrefix: (video: { appId: string; id: string }) => + `developer/${video.appId}/${video.id}/`, + internalDeveloperVideoUrl: async (key: string) => `https://storage/${key}`, + developerVideoUploadUrl: async (key: string) => `https://storage/put/${key}`, +})); +vi.mock("@/lib/developer-video-charge", () => ({ + DEVELOPER_VIDEO_MAX_DURATION_SECONDS: 4 * 60 * 60, + chargeDeveloperVideoUpload: vi.fn(async () => "charged"), +})); +vi.mock("@/lib/media-client", () => ({ + MEDIA_SERVER_SHUTDOWN_MESSAGE: "Server shutting down", + tryProbeVideoViaMediaServer: vi.fn(), + startMediaServerVideoJob: vi.fn(), + readMediaServerVideoJob: vi.fn(), +})); +vi.mock("@/lib/media-container", () => ({ sniffMediaUrl: vi.fn() })); + +const { applyDeveloperVideoProgress, failDeveloperVideoProcessing } = + await import("@/lib/developer-video-processing"); +const { enqueueDeveloperVideoEvent } = await import( + "@/lib/developer-webhook-dispatch" +); +const { developerVideoEventId } = await import( + "@/lib/developer-webhook-signature" +); +const { serializeDeveloperVideo } = await import("@/lib/developer-video-api"); + +const running = (token: string) => ({ + state: "running" as const, + token, + jobId: `job-${token}`, + startedAt: 2_000, +}); + +const ladder = { + encoder: "libx264", + renditions: [ + { + name: "1080p", + width: 1920, + height: 1080, + bandwidth: 4_000_000, + averageBandwidth: 3_000_000, + codecs: "avc1.640028,mp4a.40.2", + bytes: 5_000, + }, + ], + audio: { bytes: 300 }, +}; +const master = [ + "#EXTM3U", + "#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080", + "1080p.m3u8", +].join("\n"); + +const mp4Done = () => + applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete" }, + "mp4", + "m1", + ); +const hlsDone = () => + applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete", ladder }, + "hls", + "h1", + ); + +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function video() { + if (!mocks.store.video) throw new Error("no video"); + return mocks.store.video; +} + +const jobs = () => + mocks.start.mock.calls.map( + ([, [job]]) => + job as { + endpointId: string; + eventId: string; + eventType: string; + body: string; + }, + ); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.store.video = developerVideo({ + processing: processingState({ mp4: running("m1"), hls: running("h1") }), + }); + mocks.store.updates = []; + mocks.endpoints = [ + { + id: "wh-1", + appId: "app-1", + events: ["video.ready", "video.errored"], + enabled: true, + deletedAt: null, + }, + ]; + mocks.endpointQueries = 0; + mocks.failEndpointQuery = false; + mocks.hangEndpointQuery = false; + mocks.start.mockResolvedValue({ runId: "run-1" }); + mocks.head.mockResolvedValue({ bytes: 2_000, contentType: "video/mp4" }); + mocks.readText.mockResolvedValue(master); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +describe("video.ready", () => { + it("queues one delivery carrying the video as GET /videos/:id returns it", async () => { + await mp4Done(); + await settled(); + + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + const [job] = jobs(); + expect(job).toMatchObject({ + endpointId: "wh-1", + eventType: "video.ready", + eventId: developerVideoEventId("video-1", "video.ready"), + }); + const event = JSON.parse(job?.body ?? "{}"); + expect(event).toMatchObject({ + id: job?.eventId, + type: "video.ready", + }); + expect(Number.isNaN(Date.parse(event.createdAt))).toBe(false); + expect(event.data).toEqual( + JSON.parse(JSON.stringify(serializeDeveloperVideo(video()))), + ); + expect(event.data).toMatchObject({ id: "video-1", status: "ready" }); + expect(event.data.mp4Url).toContain("https://play.example/"); + expect(job?.body).not.toContain("s3Key"); + expect(job?.body).not.toContain("developer/app-1"); + }); + + it("queues nothing more when the encoder's callback arrives twice", async () => { + await mp4Done(); + await mp4Done(); + await mp4Done(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("queues one event when both outputs finish at once", async () => { + await Promise.all([mp4Done(), hlsDone()]); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(jobs()[0]?.eventType).toBe("video.ready"); + }); + + it("queues nothing when the second output finishes later", async () => { + await mp4Done(); + await settled(); + await hlsDone(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("queues one delivery for each endpoint that wants the event", async () => { + mocks.endpoints.push( + { + id: "wh-2", + appId: "app-1", + events: ["video.ready"], + enabled: true, + deletedAt: null, + }, + { + id: "wh-errors-only", + appId: "app-1", + events: ["video.errored"], + enabled: true, + deletedAt: null, + }, + { + id: "wh-off", + appId: "app-1", + events: ["video.ready"], + enabled: false, + deletedAt: null, + }, + { + id: "wh-other-app", + appId: "app-2", + events: ["video.ready"], + enabled: true, + deletedAt: null, + }, + ); + await mp4Done(); + await settled(); + expect(jobs().map((job) => job.endpointId)).toEqual(["wh-1", "wh-2"]); + expect(new Set(jobs().map((job) => job.eventId)).size).toBe(1); + expect(new Set(jobs().map((job) => job.body)).size).toBe(1); + }); + + it("reads nothing more and queues nothing for an app with no endpoints", async () => { + mocks.endpoints = []; + await mp4Done(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.endpointQueries).toBe(1); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("asks about endpoints only when the status changes", async () => { + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "processing", jobId: "job-9" }, + "mp4", + "m1", + ); + await settled(); + expect(mocks.endpointQueries).toBe(0); + }); +}); + +describe("video.errored", () => { + it("queues one event when processing fails, and none when failed again", async () => { + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await settled(); + + expect(video().status).toBe("errored"); + expect(mocks.start).toHaveBeenCalledTimes(1); + const [job] = jobs(); + expect(job).toMatchObject({ + eventType: "video.errored", + eventId: developerVideoEventId("video-1", "video.errored"), + }); + expect(JSON.parse(job?.body ?? "{}").data).toMatchObject({ + status: "errored", + errorReason: "Too long", + }); + }); + + it("queues nothing for an output that fails after the video is ready", async () => { + await mp4Done(); + await settled(); + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await settled(); + expect(video().status).toBe("ready"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + }); +}); + +describe("queueing an event", () => { + it("sends nothing for a status that was never committed", async () => { + const queued = await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ); + expect(queued).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("sends nothing for a deleted video", async () => { + mocks.store.video = developerVideo({ + status: "ready", + deletedAt: new Date(), + }); + expect( + await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ), + ).toBe(0); + }); + + it("uses the same event id and data when it is called again", async () => { + mocks.store.video = developerVideo({ status: "ready" }); + const target = { id: "video-1", appId: "app-1" }; + await enqueueDeveloperVideoEvent(target, "video.ready"); + await enqueueDeveloperVideoEvent(target, "video.ready"); + const [first, second] = jobs(); + expect(first?.eventId).toBe(second?.eventId); + expect(JSON.parse(first?.body ?? "{}").data).toEqual( + JSON.parse(second?.body ?? "{}").data, + ); + }); +}); + +describe("webhooks never hold up a video", () => { + it("marks the video ready while the customer's endpoint is still hanging", async () => { + const hanging = new Promise(() => {}); + mocks.start.mockReturnValue(hanging); + + const started = Date.now(); + const result = await mp4Done(); + + expect(result).toEqual({ success: true, applied: true }); + expect(video().status).toBe("ready"); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it("marks the video ready when queueing the delivery fails", async () => { + mocks.start.mockRejectedValue(new Error("queue is down")); + expect(await mp4Done()).toEqual({ success: true, applied: true }); + await settled(); + expect(video().status).toBe("ready"); + }); + + it("marks the video ready when the endpoint lookup fails", async () => { + mocks.failEndpointQuery = true; + expect(await mp4Done()).toEqual({ success: true, applied: true }); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("records a failure while the endpoint lookup is still hanging", async () => { + mocks.hangEndpointQuery = true; + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + expect(video().status).toBe("errored"); + expect(mocks.endpointQueries).toBe(1); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-fake-db.ts b/apps/web/__tests__/unit/developer-webhook-fake-db.ts new file mode 100644 index 00000000000..f0999bf7afa --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-fake-db.ts @@ -0,0 +1,100 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { createFakeDatabase } from "./developer-transcript-fake-db"; + +type Row = Record; + +const dialect = new MySqlDialect(); + +const isSql = (value: unknown): value is SQL => + typeof value === "object" && value !== null && "queryChunks" in value; + +const keepsColumn = (value: unknown) => + isSql(value) && + /^`[^`]+`(\.`[^`]+`)?$/.test(dialect.sqlToQuery(value).sql.trim()); + +export const webhookEndpointDefaults = (): Row => ({ + events: ["video.ready", "video.errored"], + enabled: true, + disabledReason: null, + lastDeliveryAt: null, + lastDeliveryStatus: null, + lastResponseStatus: null, + lastErrorCode: null, + consecutiveFailures: 0, + failingSince: null, + deletedAt: null, +}); + +// Adds the two things the endpoint queries use that the shared fake does not +// read: a join to the app table, and an update that leaves updatedAt alone. +export function createWebhookFakeDatabase( + defaults: Map Row>, + options: { joined?: (row: Row) => boolean } = {}, +) { + const fake = createFakeDatabase(defaults); + const base = fake.db; + + const select = (fields?: Record) => ({ + from: (table: object) => { + const state = { joined: false }; + const chain = base.select(fields).from(table); + const rows = async () => { + const found = await chain; + return state.joined && options.joined + ? found.filter(options.joined) + : found; + }; + const wrapped = Object.assign(Promise.resolve().then(rows), { + innerJoin() { + state.joined = true; + return wrapped; + }, + where(condition: SQL | undefined) { + chain.where(condition); + return wrapped; + }, + orderBy() { + chain.orderBy(); + return wrapped; + }, + limit(count: number) { + chain.limit(count); + return wrapped; + }, + offset(count: number) { + chain.offset(count); + return wrapped; + }, + for() { + return wrapped; + }, + }); + return wrapped; + }, + }); + + const update = (table: object) => ({ + set: (values: Row) => + base + .update(table) + .set( + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + keepsColumn(value) ? undefined : value, + ]), + ), + ), + }); + + const client = { ...base, select, update }; + return { + ...fake, + db: { + ...client, + transaction: (run: (tx: typeof client) => Promise) => + base.transaction(() => run(client)), + }, + }; +} diff --git a/apps/web/__tests__/unit/developer-webhook-guard.test.ts b/apps/web/__tests__/unit/developer-webhook-guard.test.ts new file mode 100644 index 00000000000..50251156ad4 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-guard.test.ts @@ -0,0 +1,283 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { + allowsPrivateWebhookTargets, + checkWebhookUrl, + sendDeveloperWebhook, + webhookUrlProblem, +} = await import("@/lib/developer-webhook-delivery"); +const { isBlockedS3Endpoint, isPrivateAddress } = await import( + "@/lib/s3-endpoint-policy" +); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); + +const resolvesTo = + (...addresses: string[]) => + (url: string) => + isBlockedS3Endpoint(url, async () => addresses); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready" }), +}; + +describe("webhook URL policy in a production build", () => { + it("is strict in production", () => { + expect(allowsPrivateWebhookTargets()).toBe(false); + }); + + it("accepts a public https URL", () => { + expect(webhookUrlProblem("https://hooks.example.com/cap?x=1")).toBeNull(); + }); + + it.each([ + ["plain http", "http://hooks.example.com/cap", "https_required"], + ["another scheme", "ftp://hooks.example.com/cap", "https_required"], + ["a file URL", "file:///etc/passwd", "https_required"], + ["no scheme", "hooks.example.com/cap", "invalid_url"], + ["an empty value", "", "invalid_url"], + [ + "a URL over the length cap", + `https://hooks.example.com/${"a".repeat(2_048)}`, + "invalid_url", + ], + [ + "a username and password", + "https://user:pass@hooks.example.com/cap", + "credentials_in_url", + ], + ["another port", "https://hooks.example.com:8443/cap", "unsupported_port"], + ["localhost", "https://localhost/cap", "private_address"], + ["a localhost subdomain", "https://app.localhost/cap", "private_address"], + ["an internal name", "https://db.internal/cap", "private_address"], + ["a .local name", "https://printer.local/cap", "private_address"], + ["loopback v4", "https://127.0.0.1/cap", "private_address"], + ["private 10/8", "https://10.1.2.3/cap", "private_address"], + ["private 172.16/12", "https://172.20.0.1/cap", "private_address"], + ["private 192.168/16", "https://192.168.1.10/cap", "private_address"], + ["carrier NAT", "https://100.64.0.1/cap", "private_address"], + ["the metadata address", "https://169.254.169.254/cap", "private_address"], + ["the unspecified address", "https://0.0.0.0/cap", "private_address"], + ["a documentation range", "https://203.0.113.9/cap", "private_address"], + ["multicast", "https://224.0.0.1/cap", "private_address"], + ["a decimal loopback", "https://2130706433/cap", "private_address"], + ["a hex loopback", "https://0x7f.1/cap", "private_address"], + ["an octal loopback", "https://017700000001/cap", "private_address"], + ["loopback v6", "https://[::1]/cap", "private_address"], + ["unique local v6", "https://[fd00:ec2::254]/cap", "private_address"], + ["link-local v6", "https://[fe80::1]/cap", "private_address"], + ["mapped v4 loopback", "https://[::ffff:127.0.0.1]/cap", "private_address"], + ["mapped v4 private", "https://[::ffff:a00:1]/cap", "private_address"], + [ + "mapped v4 metadata", + "https://[::ffff:169.254.169.254]/cap", + "private_address", + ], + [ + "NAT64 of a private v4", + "https://[64:ff9b::a00:1]/cap", + "private_address", + ], + ["6to4 of loopback", "https://[2002:7f00:1::]/cap", "private_address"], + ["Teredo", "https://[2001:0:4136:e378::1]/cap", "private_address"], + ])("refuses %s", (_label, url, problem) => { + expect(webhookUrlProblem(url)).toBe(problem); + }); + + it("refuses a name that resolves to a private address", async () => { + for (const address of ["10.0.0.5", "127.0.0.1", "::1", "::ffff:10.0.0.5"]) { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo(address), + }), + ).toBe("private_address"); + } + }); + + it("refuses a name with one private address among public ones", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo("93.184.216.34", "192.168.0.7"), + }), + ).toBe("private_address"); + }); + + it("refuses a name that does not resolve", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo(), + }), + ).toBe("private_address"); + }); + + it("accepts a name that resolves to public addresses only", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo("93.184.216.34", "2606:2800:220:1::1"), + }), + ).toBeNull(); + }); + + it("covers the reserved ranges added for webhooks", () => { + for (const address of [ + "192.0.2.1", + "198.51.100.1", + "203.0.113.1", + "192.88.99.1", + "2001:db8::1", + "100::1", + "64:ff9b:1::1", + "2002:a00:1::1", + "2001:0:1::1", + ]) { + expect(isPrivateAddress(address)).toBe(true); + } + for (const address of ["93.184.216.34", "8.8.8.8", "2606:4700::1111"]) { + expect(isPrivateAddress(address)).toBe(false); + } + }); +}); + +describe("sending a webhook in a production build", () => { + let server: Server; + let port: number; + let hits = 0; + + beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("internal"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("does not connect to a private address written as a literal", async () => { + await expect( + requestPublicOnly(`http://127.0.0.1:${port}/`, { method: "POST" }), + ).rejects.toThrow("Refused to connect to private address"); + await expect( + requestPublicOnly(`http://[::ffff:127.0.0.1]:${port}/`), + ).rejects.toThrow("Refused to connect to private address"); + expect(hits).toBe(0); + }); + + it("does not connect when the name resolves to a private address at connect time", async () => { + const request = vi.fn((url: string, init: RequestInit) => + requestPublicOnly(url.replace("hooks.example.com", "localhost"), init), + ); + const result = await sendDeveloperWebhook( + { ...event, url: `https://hooks.example.com/cap` }, + { isBlocked: async () => false, request }, + ); + expect(request).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(hits).toBe(0); + }); + + it("sends nothing when the URL no longer passes the policy", async () => { + const request = vi.fn(); + for (const url of [ + "http://hooks.example.com/cap", + "https://127.0.0.1/cap", + "https://user:pass@hooks.example.com/cap", + ]) { + const result = await sendDeveloperWebhook({ ...event, url }, { request }); + expect(result.ok).toBe(false); + } + const rebound = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { request, isBlocked: resolvesTo("10.0.0.5") }, + ); + expect(rebound).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("treats a redirect as a failed delivery and never follows it", async () => { + const request = vi.fn( + async () => + new Response("moved", { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data/" }, + }), + ); + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { isBlocked: async () => false, request }, + ); + expect(result).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 302, + }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("signs the request and names the event in headers", async () => { + const seen: { url: string; init: RequestInit }[] = []; + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { + isBlocked: async () => false, + request: async (url, init) => { + seen.push({ url, init }); + return new Response(null, { status: 204 }); + }, + }, + ); + expect(result).toMatchObject({ ok: true, responseStatus: 204 }); + const [sent] = seen; + const headers = new Headers(sent?.init.headers); + expect(sent?.init.method).toBe("POST"); + expect(sent?.init.body).toBe(event.body); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("cap-event-id")).toBe("evt_1"); + expect(headers.get("cap-event-type")).toBe("video.ready"); + expect( + verifyWebhookSignature({ + secret: event.secret, + body: event.body, + header: headers.get("cap-signature"), + }), + ).toBe(true); + }); + + it.each([ + [200, true], + [201, true], + [299, true], + [400, false], + [404, false], + [500, false], + ])("counts %i as delivered: %s", async (status, delivered) => { + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { + isBlocked: async () => false, + request: async () => new Response("x", { status }), + }, + ); + expect(result.ok).toBe(delivered); + expect(result.responseStatus).toBe(status); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-signature.test.ts b/apps/web/__tests__/unit/developer-webhook-signature.test.ts new file mode 100644 index 00000000000..627c157b131 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-signature.test.ts @@ -0,0 +1,122 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_RETRY_DELAYS_SECONDS, + webhookRetryDelaySeconds, +} from "@/lib/developer-webhook-events"; +import { + createWebhookSecret, + developerVideoEventId, + signWebhookBody, + verifyWebhookSignature, +} from "@/lib/developer-webhook-signature"; + +const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; +const body = JSON.stringify({ id: "evt_1", type: "video.ready" }); +const at = 1_794_500_000; + +describe("webhook signature", () => { + it("signs the timestamp and the raw body with the endpoint secret", () => { + const expected = createHmac("sha256", secret) + .update(`${at}.${body}`) + .digest("hex"); + expect(signWebhookBody(secret, body, at)).toBe(`t=${at},v1=${expected}`); + }); + + it("accepts its own signature inside the five minute window", () => { + const header = signWebhookBody(secret, body, at); + for (const drift of [0, 299, -299]) { + expect( + verifyWebhookSignature({ + secret, + body, + header, + now: (at + drift) * 1000, + }), + ).toBe(true); + } + }); + + it("rejects a timestamp older than five minutes", () => { + const header = signWebhookBody(secret, body, at); + expect( + verifyWebhookSignature({ secret, body, header, now: (at + 301) * 1000 }), + ).toBe(false); + }); + + it("rejects a changed body, another secret and a moved timestamp", () => { + const header = signWebhookBody(secret, body, at); + const now = at * 1000; + expect( + verifyWebhookSignature({ secret, body: `${body} `, header, now }), + ).toBe(false); + expect( + verifyWebhookSignature({ secret: `${secret}x`, body, header, now }), + ).toBe(false); + expect( + verifyWebhookSignature({ + secret, + body, + header: header.replace(`t=${at}`, `t=${at + 1}`), + now, + }), + ).toBe(false); + }); + + it.each([ + null, + "", + "v1=abc", + `t=${at}`, + `t=${at},v1=zz`, + `t=soon,v1=${"a".repeat(64)}`, + ])("rejects the malformed header %j", (header) => { + expect( + verifyWebhookSignature({ secret, body, header, now: at * 1000 }), + ).toBe(false); + }); + + it("makes secrets with a clear prefix and enough randomness", () => { + const first = createWebhookSecret(); + expect(first).toMatch(/^cws_[0-9a-z]{30}$/); + expect(createWebhookSecret()).not.toBe(first); + }); +}); + +describe("webhook event ids", () => { + it("names the same event for the same video and type every time", () => { + const id = developerVideoEventId("video-1", "video.ready"); + expect(id).toMatch(/^evt_[0-9a-f]{24}$/); + expect(developerVideoEventId("video-1", "video.ready")).toBe(id); + }); + + it("names a different event for another type or another video", () => { + const id = developerVideoEventId("video-1", "video.ready"); + expect(developerVideoEventId("video-1", "video.errored")).not.toBe(id); + expect(developerVideoEventId("video-2", "video.ready")).not.toBe(id); + }); +}); + +describe("webhook retry schedule", () => { + it("makes eight attempts with growing gaps over about a day", () => { + expect(WEBHOOK_MAX_ATTEMPTS).toBe(8); + expect([...WEBHOOK_RETRY_DELAYS_SECONDS]).toEqual([ + 60, 300, 1_800, 7_200, 18_000, 28_800, 28_800, + ]); + const total = WEBHOOK_RETRY_DELAYS_SECONDS.reduce((a, b) => a + b, 0); + expect(total).toBeGreaterThan(22 * 3_600); + expect(total).toBeLessThan(26 * 3_600); + for (let i = 1; i < WEBHOOK_RETRY_DELAYS_SECONDS.length; i++) { + expect(WEBHOOK_RETRY_DELAYS_SECONDS[i]).toBeGreaterThanOrEqual( + WEBHOOK_RETRY_DELAYS_SECONDS[i - 1] ?? 0, + ); + } + }); + + it("has a wait after every attempt but the last", () => { + expect(webhookRetryDelaySeconds(1)).toBe(60); + expect(webhookRetryDelaySeconds(7)).toBe(28_800); + expect(webhookRetryDelaySeconds(8)).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts new file mode 100644 index 00000000000..b5773286c37 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + reset: () => void; + }, + resolved: ["93.184.216.34"] as string[], + request: null as + | null + | ((url: string, init: RequestInit) => Promise), +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createWebhookFakeDatabase, webhookEndpointDefaults } = await import( + "./developer-webhook-fake-db" + ); + const fake = createWebhookFakeDatabase( + new Map([[schema.developerWebhookEndpoints, webhookEndpointDefaults]]), + ); + context.fake = fake; + return { db: () => fake.db }; +}); +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { + NEXT_PUBLIC_IS_CAP: "true", + NEXT_PUBLIC_WEB_URL: "https://cap.so", + }, + serverEnv: () => ({ DATABASE_ENCRYPTION_KEY: "ab".repeat(32) }), +})); +vi.mock("@/app/api/utils", async () => { + const { createMiddleware } = await import("hono/factory"); + const apps: Record = { + csk_app_a: "app-a", + csk_app_b: "app-b", + }; + return { + withDeveloperSecretAuth: createMiddleware(async (c, next) => { + const key = c.req.header("authorization")?.split(" ")[1] ?? ""; + const appId = apps[key]; + if (!appId) return c.json({ code: "invalid_secret_key" }, 401); + c.set("developerAppId", appId); + await next(); + }), + }; +}); +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isBlockedS3Endpoint: (url: string) => + actual.isBlockedS3Endpoint(url, async () => context.resolved), + }; +}); +vi.mock("@/lib/storage-fetch", () => ({ + requestPublicOnly: (url: string, init: RequestInit) => { + if (!context.request) throw new Error("no request expected"); + return context.request(url, init); + }, +})); + +const { developerWebhookEndpoints } = await import("@cap/database/schema"); +const { decrypt } = await import("@cap/database/crypto"); +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); + +const A = { Authorization: "Bearer csk_app_a" }; +const B = { Authorization: "Bearer csk_app_b" }; + +const send = ( + method: string, + path: string, + headers: Record, + body?: unknown, +) => + app.request(path, { + method, + headers: { + ...headers, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +async function create( + headers: Record = A, + body: Record = { url: "https://hooks.example.com/cap" }, +) { + const response = await send("POST", "/", headers, body); + return { + status: response.status, + body: (await response.json()) as { + data: Record & { id: string; secret: string }; + code?: string; + hint?: string; + }, + }; +} + +const rows = () => context.fake?.rows(developerWebhookEndpoints) ?? []; + +beforeEach(() => { + context.fake?.reset(); + context.resolved = ["93.184.216.34"]; + context.request = null; +}); + +describe("creating a webhook endpoint", () => { + it("returns the signing secret once and stores it encrypted", async () => { + const created = await create(); + expect(created.status).toBe(200); + const { id, secret } = created.body.data; + expect(secret).toMatch(/^cws_[0-9a-z]{30}$/); + expect(created.body.data).toMatchObject({ + url: "https://hooks.example.com/cap", + events: ["video.ready", "video.errored"], + enabled: true, + disabledReason: null, + secretPrefix: secret.slice(0, 12), + lastDeliveryAt: null, + consecutiveFailures: 0, + }); + + const [row] = rows(); + expect(row?.encryptedSecret).not.toContain(secret); + expect(await decrypt(String(row?.encryptedSecret))).toBe(secret); + + for (const response of [ + await send("GET", `/${id}`, A), + await send("GET", "/", A), + await send("PATCH", `/${id}`, A, { enabled: false }), + await send("POST", `/${id}/test`, A), + ]) { + const text = await response.text(); + expect(text).not.toContain(secret); + expect(text).not.toContain("encryptedSecret"); + } + }); + + it("takes the events and enabled flag it is given", async () => { + const created = await create(A, { + url: "https://hooks.example.com/cap", + events: ["video.errored"], + enabled: false, + }); + expect(created.body.data).toMatchObject({ + events: ["video.errored"], + enabled: false, + }); + }); + + it.each([ + ["an unknown field", { url: "https://hooks.example.com/cap", name: "x" }], + ["a missing url", {}], + ["an unknown event", { url: "https://a.example/x", events: ["video.up"] }], + ["no events", { url: "https://a.example/x", events: [] }], + [ + "an event listed twice", + { url: "https://a.example/x", events: ["video.ready", "video.ready"] }, + ], + [ + "a secret of the caller's choosing", + { url: "https://a.example/x", secret: "s" }, + ], + ])("refuses %s", async (_label, body) => { + const created = await create(A, body); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_request"); + expect(rows()).toHaveLength(0); + }); + + it.each([ + ["http", "http://hooks.example.com/cap"], + ["a private address", "https://10.0.0.8/cap"], + ["the metadata address", "https://169.254.169.254/latest"], + ["a mapped loopback address", "https://[::ffff:127.0.0.1]/cap"], + ["credentials", "https://user:pass@hooks.example.com/cap"], + ["another port", "https://hooks.example.com:8080/cap"], + ])("refuses a URL with %s", async (_label, url) => { + const created = await create(A, { url }); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + }); + + it("refuses a name that resolves to a private address", async () => { + context.resolved = ["192.168.1.20"]; + const created = await create(); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + }); + + it("stops at five endpoints per app, and counts each app on its own", async () => { + for (let i = 0; i < 5; i++) { + expect((await create(A)).status).toBe(200); + } + const sixth = await create(A); + expect(sixth.status).toBe(409); + expect(sixth.body.code).toBe("webhook_limit_reached"); + expect(rows()).toHaveLength(5); + + expect((await create(B)).status).toBe(200); + }); + + it("does not let simultaneous requests pass the limit", async () => { + const results = await Promise.all( + Array.from({ length: 8 }, () => create(A)), + ); + expect(results.filter((result) => result.status === 200)).toHaveLength(5); + expect(rows()).toHaveLength(5); + }); + + it("frees a place when an endpoint is deleted", async () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) ids.push((await create(A)).body.data.id); + expect((await send("DELETE", `/${ids[0]}`, A)).status).toBe(200); + expect((await create(A)).status).toBe(200); + }); +}); + +describe("reading and changing webhook endpoints", () => { + it("lists an app's endpoints newest first with pagination", async () => { + const first = (await create(A)).body.data.id; + const second = (await create(A)).body.data.id; + const list = await (await send("GET", "/?limit=1", A)).json(); + expect(list.data.map((row: { id: string }) => row.id)).toEqual([second]); + expect(list.pagination).toEqual({ limit: 1, offset: 0, hasMore: true }); + const next = await (await send("GET", "/?limit=1&offset=1", A)).json(); + expect(next.data.map((row: { id: string }) => row.id)).toEqual([first]); + expect(next.pagination.hasMore).toBe(false); + }); + + it("refuses an unknown query parameter on the list", async () => { + const response = await send("GET", "/?userId=u1", A); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "unknown_parameter" }); + }); + + it("updates url, events and enabled", async () => { + const { id } = (await create(A)).body.data; + const response = await send("PATCH", `/${id}`, A, { + url: "https://other.example.com/hook", + events: ["video.ready"], + enabled: false, + }); + expect(response.status).toBe(200); + expect((await response.json()).data).toMatchObject({ + url: "https://other.example.com/hook", + events: ["video.ready"], + enabled: false, + }); + }); + + it("refuses an update with nothing in it, an unknown field or a bad URL", async () => { + const { id } = (await create(A)).body.data; + expect((await send("PATCH", `/${id}`, A, {})).status).toBe(400); + expect((await send("PATCH", `/${id}`, A, { secret: "x" })).status).toBe( + 400, + ); + const bad = await send("PATCH", `/${id}`, A, { + url: "https://127.0.0.1/x", + }); + expect(bad.status).toBe(400); + expect(await bad.json()).toMatchObject({ code: "invalid_webhook_url" }); + expect(rows()[0]?.url).toBe("https://hooks.example.com/cap"); + }); + + it("turns a disabled endpoint back on with a clean failure streak", async () => { + const { id } = (await create(A)).body.data; + Object.assign(rows()[0] ?? {}, { + enabled: false, + disabledReason: "delivery_failures", + consecutiveFailures: 31, + failingSince: new Date("2026-11-01T00:00:00Z"), + }); + const response = await send("PATCH", `/${id}`, A, { enabled: true }); + expect((await response.json()).data).toMatchObject({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }); + expect(rows()[0]?.failingSince).toBeNull(); + }); + + it("deletes an endpoint, and a second delete still succeeds", async () => { + const { id } = (await create(A)).body.data; + expect(await (await send("DELETE", `/${id}`, A)).json()).toEqual({ + success: true, + }); + expect((await send("GET", `/${id}`, A)).status).toBe(404); + expect((await send("DELETE", `/${id}`, A)).status).toBe(200); + expect((await send("GET", "/", A)).status).toBe(200); + expect((await (await send("GET", "/", A)).json()).data).toEqual([]); + }); +}); + +describe("one app cannot reach another app's endpoints", () => { + it("answers not found for every call made with another app's key", async () => { + const { id } = (await create(A)).body.data; + context.request = async () => new Response(null, { status: 200 }); + + const attempts = [ + await send("GET", `/${id}`, B), + await send("PATCH", `/${id}`, B, { enabled: false }), + await send("PATCH", `/${id}`, B, { url: "https://evil.example.com/x" }), + await send("DELETE", `/${id}`, B), + await send("POST", `/${id}/test`, B), + ]; + for (const response of attempts) { + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + code: "webhook_not_found", + }); + } + + expect((await (await send("GET", "/", B)).json()).data).toEqual([]); + expect(rows()[0]).toMatchObject({ + id, + appId: "app-a", + url: "https://hooks.example.com/cap", + enabled: true, + deletedAt: null, + lastDeliveryAt: null, + }); + expect((await send("GET", `/${id}`, A)).status).toBe(200); + }); + + it("refuses a call with no valid key", async () => { + const { id } = (await create(A)).body.data; + for (const [method, path] of [ + ["GET", "/"], + ["GET", `/${id}`], + ["PATCH", `/${id}`], + ["DELETE", `/${id}`], + ["POST", `/${id}/test`], + ["POST", "/"], + ] as const) { + expect((await send(method, path, {})).status).toBe(401); + } + }); +}); + +describe("sending a test event", () => { + it("sends one signed webhook.test event and reports what came back", async () => { + const { id, secret } = (await create(A)).body.data; + const seen: { url: string; init: RequestInit }[] = []; + context.request = async (url, init) => { + seen.push({ url, init }); + return new Response("never shown to the caller", { status: 200 }); + }; + + const response = await send("POST", `/${id}/test`, A); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("never shown"); + const { data } = JSON.parse(text); + expect(data).toMatchObject({ + type: "webhook.test", + delivered: true, + responseStatus: 200, + errorCode: null, + }); + expect(data.eventId).toMatch(/^evt_test_/); + + expect(seen).toHaveLength(1); + const headers = new Headers(seen[0]?.init.headers); + const body = String(seen[0]?.init.body); + expect(JSON.parse(body)).toMatchObject({ + id: data.eventId, + type: "webhook.test", + data: { webhookId: id }, + }); + expect(headers.get("cap-event-type")).toBe("webhook.test"); + expect( + verifyWebhookSignature({ + secret, + body, + header: headers.get("cap-signature"), + }), + ).toBe(true); + expect(rows()[0]).toMatchObject({ + lastDeliveryStatus: "succeeded", + lastResponseStatus: 200, + }); + }); + + it("reports a failing endpoint without counting it towards disabling", async () => { + const { id } = (await create(A)).body.data; + context.request = async () => new Response("boom", { status: 500 }); + const { data } = await (await send("POST", `/${id}/test`, A)).json(); + expect(data).toMatchObject({ + delivered: false, + responseStatus: 500, + errorCode: "http_error", + }); + expect(rows()[0]).toMatchObject({ + consecutiveFailures: 0, + failingSince: null, + lastDeliveryStatus: "failed", + }); + }); + + it("sends nothing when the URL now resolves to a private address", async () => { + const { id } = (await create(A)).body.data; + context.resolved = ["10.0.0.9"]; + const { data } = await (await send("POST", `/${id}/test`, A)).json(); + expect(data).toMatchObject({ + delivered: false, + errorCode: "private_address", + }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhooks-auth.test.ts b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts new file mode 100644 index 00000000000..f8bbfc9b86c --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queries: 0, + keyRows: [] as { appId: string }[], + store: { + createWebhookEndpoint: vi.fn(), + deleteWebhookEndpoint: vi.fn(), + getWebhookEndpoint: vi.fn(), + listWebhookEndpoints: vi.fn(), + sendWebhookTestEvent: vi.fn(), + updateWebhookEndpoint: vi.fn(), + }, +})); + +vi.mock("@cap/database", () => { + const chain = { + select: () => { + mocks.queries += 1; + return chain; + }, + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: () => chain, + limit: async () => mocks.keyRows, + update: () => { + mocks.queries += 1; + return chain; + }, + set: () => chain, + catch: () => chain, + }; + return { db: () => chain }; +}); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@cap/database/auth/domain-utils", () => ({ + isBlockedAccountEmail: () => false, +})); +vi.mock("@cap/database/auth/request-site", () => ({ + isCrossSiteCookieWrite: () => false, +})); +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, +})); +vi.mock("@/lib/developer-key-hash", () => ({ + hashKey: async (key: string) => `hash:${key}`, +})); +vi.mock("@/lib/developer-webhooks", () => ({ + ...mocks.store, + serializeWebhookEndpoint: (endpoint: unknown) => endpoint, + serializeWebhookTestResult: (eventId: string) => ({ eventId }), +})); + +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); + +const ROUTES = [ + ["GET", "/"], + ["POST", "/"], + ["GET", "/wh-1"], + ["PATCH", "/wh-1"], + ["DELETE", "/wh-1"], + ["POST", "/wh-1/test"], +] as const; + +const call = (method: string, path: string, headers: Record) => + app.request(path, { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: + method === "POST" || method === "PATCH" + ? JSON.stringify({ url: "https://hooks.example.com/cap" }) + : undefined, + }); + +beforeEach(() => { + mocks.queries = 0; + mocks.keyRows = [{ appId: "app-1" }]; + for (const mock of Object.values(mocks.store)) mock.mockReset(); +}); + +describe("webhook routes and a bad key", () => { + it.each([ + ["no key", {}], + ["a public key", { Authorization: "Bearer cpk_public_key" }], + ["an upload token", { Authorization: "Bearer cut_upload_token" }], + ["an agent token", { Authorization: "Bearer cap_cli_token" }], + ["a signing secret", { Authorization: "Bearer cws_signing_secret" }], + ["a malformed header", { Authorization: "csk_valid_key" }], + ])("refuses %s without a single database query", async (_label, headers) => { + for (const [method, path] of ROUTES) { + const response = await call(method, path, headers); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ + code: "invalid_secret_key", + }); + } + expect(mocks.queries).toBe(0); + for (const mock of Object.values(mocks.store)) { + expect(mock).not.toHaveBeenCalled(); + } + }); + + it("refuses an unknown or revoked secret key before reaching any endpoint", async () => { + mocks.keyRows = []; + for (const [method, path] of ROUTES) { + const response = await call(method, path, { + Authorization: "Bearer csk_revoked", + }); + expect(response.status).toBe(401); + } + expect(mocks.queries).toBe(ROUTES.length); + for (const mock of Object.values(mocks.store)) { + expect(mock).not.toHaveBeenCalled(); + } + }); + + it("passes the app that owns the key to every call", async () => { + mocks.keyRows = [{ appId: "app-from-key" }]; + mocks.store.listWebhookEndpoints.mockResolvedValue({ + endpoints: [], + hasMore: false, + }); + mocks.store.getWebhookEndpoint.mockResolvedValue(null); + const missing = { + ok: false, + status: 404, + code: "webhook_not_found", + message: "Webhook endpoint not found", + hint: "", + }; + mocks.store.createWebhookEndpoint.mockResolvedValue(missing); + mocks.store.updateWebhookEndpoint.mockResolvedValue(missing); + mocks.store.deleteWebhookEndpoint.mockResolvedValue(missing); + mocks.store.sendWebhookTestEvent.mockResolvedValue(missing); + + for (const [method, path] of ROUTES) { + await call(method, path, { Authorization: "Bearer csk_valid_key" }); + } + for (const mock of Object.values(mocks.store)) { + expect(mock).toHaveBeenCalledTimes(1); + expect(mock.mock.calls[0]?.[0]).toBe("app-from-key"); + } + }); +}); diff --git a/apps/web/app/api/developer/v1/[...route]/route.ts b/apps/web/app/api/developer/v1/[...route]/route.ts index a0c26a9e2ff..fd7b8c65079 100644 --- a/apps/web/app/api/developer/v1/[...route]/route.ts +++ b/apps/web/app/api/developer/v1/[...route]/route.ts @@ -11,6 +11,7 @@ import * as transcripts from "./transcripts"; import * as uploads from "./uploads"; import * as usage from "./usage"; import * as videos from "./videos"; +import * as webhooks from "./webhooks"; const app = new Hono() .basePath("/api/developer/v1") @@ -20,6 +21,7 @@ const app = new Hono() .route("/transcripts", transcripts.app) .route("/usage", usage.app) .route("/transactions", transactions.app) + .route("/webhooks", webhooks.app) .onError(developerApiError); app.notFound((c) => developerApiNotFound(c, app.routes)); diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts new file mode 100644 index 00000000000..58f1e79f4da --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -0,0 +1,143 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { apiErrorBody } from "@/lib/api-errors"; +import { parseDeveloperListQuery } from "@/lib/developer-pagination"; +import { WEBHOOK_EVENT_TYPES } from "@/lib/developer-webhook-events"; +import { + createWebhookEndpoint, + deleteWebhookEndpoint, + getWebhookEndpoint, + listWebhookEndpoints, + sendWebhookTestEvent, + serializeWebhookEndpoint, + serializeWebhookTestResult, + updateWebhookEndpoint, + type WebhookFailure, +} from "@/lib/developer-webhooks"; +import { developerZValidator } from "@/utils/zod-validator"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +const failureBody = (failure: WebhookFailure) => + apiErrorBody(failure.code, failure.message, failure.hint); + +const endpointFields = { + url: z.string().min(1).max(2048), + events: z + .array(z.enum(WEBHOOK_EVENT_TYPES)) + .min(1) + .refine((types) => new Set(types).size === types.length, { + message: "Each event type can be listed once", + }), + enabled: z.boolean(), +}; + +app.post( + "/", + developerZValidator( + "json", + z.strictObject({ + url: endpointFields.url, + events: endpointFields.events.optional(), + enabled: endpointFields.enabled.optional(), + }), + ), + async (c) => { + const result = await createWebhookEndpoint( + c.get("developerAppId"), + c.req.valid("json"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: { + ...serializeWebhookEndpoint(result.endpoint), + secret: result.secret, + }, + }); + }, +); + +app.get("/", async (c) => { + const parsed = parseDeveloperListQuery(c.req.query(), []); + if (!parsed.ok) { + return c.json(apiErrorBody(parsed.code, parsed.message, parsed.hint), 400); + } + const { limit, offset } = parsed; + const { endpoints, hasMore } = await listWebhookEndpoints( + c.get("developerAppId"), + { limit, offset }, + ); + return c.json({ + data: endpoints.map(serializeWebhookEndpoint), + pagination: { limit, offset, hasMore }, + }); +}); + +app.get("/:id", async (c) => { + const endpoint = await getWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!endpoint) { + return c.json( + apiErrorBody( + "webhook_not_found", + "Webhook endpoint not found", + "Check the endpoint ID and that it belongs to this app. Deleted endpoints are not returned.", + ), + 404, + ); + } + return c.json({ data: serializeWebhookEndpoint(endpoint) }); +}); + +app.patch( + "/:id", + developerZValidator( + "json", + z + .strictObject({ + url: endpointFields.url.optional(), + events: endpointFields.events.optional(), + enabled: endpointFields.enabled.optional(), + }) + .refine((fields) => Object.keys(fields).length > 0, { + message: "Send at least one of url, events or enabled", + }), + ), + async (c) => { + const result = await updateWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + c.req.valid("json"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ data: serializeWebhookEndpoint(result.endpoint) }); + }, +); + +app.delete("/:id", async (c) => { + const result = await deleteWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ success: true }); +}); + +app.post("/:id/test", async (c) => { + const result = await sendWebhookTestEvent( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: serializeWebhookTestResult(result.eventId, result.result), + }); +}); diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index d74d6b805ac..d3b0681cde2 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -57,6 +57,7 @@ import { internalDeveloperVideoUrl, readDeveloperVideoText, } from "./developer-video-storage"; +import { announceDeveloperVideoStatus } from "./developer-webhook-dispatch"; import { MEDIA_SERVER_SHUTDOWN_MESSAGE, readMediaServerVideoJob, @@ -123,6 +124,7 @@ async function mutateDeveloperVideo( .update(developerVideos) .set(updates) .where(eq(developerVideos.id, videoId)); + announceDeveloperVideoStatus(video, updates); return { ...video, ...updates } as DeveloperVideoRow; }; return executor ? run(executor) : db().transaction(run); diff --git a/apps/web/lib/developer-webhook-delivery.ts b/apps/web/lib/developer-webhook-delivery.ts new file mode 100644 index 00000000000..033a3e31b21 --- /dev/null +++ b/apps/web/lib/developer-webhook-delivery.ts @@ -0,0 +1,294 @@ +import { db } from "@cap/database"; +import { decrypt } from "@cap/database/crypto"; +import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; +import { NODE_ENV } from "@cap/env"; +import { and, eq, gte, isNull, lte, sql } from "drizzle-orm"; +import { + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_DISABLED_BY_FAILURES, + WEBHOOK_TIMEOUT_MS, + type WebhookDeliveryJob, + type WebhookDeliveryOutcome, +} from "@/lib/developer-webhook-events"; +import { + signWebhookBody, + WEBHOOK_EVENT_ID_HEADER, + WEBHOOK_EVENT_TYPE_HEADER, + WEBHOOK_SIGNATURE_HEADER, +} from "@/lib/developer-webhook-signature"; +import { publicMediaUrlProblem } from "@/lib/public-media-fetch"; +import { isBlockedS3Endpoint } from "@/lib/s3-endpoint-policy"; +import { requestPublicOnly } from "@/lib/storage-fetch"; + +export type WebhookUrlProblem = + | "invalid_url" + | "https_required" + | "credentials_in_url" + | "unsupported_port" + | "private_address"; + +export type WebhookErrorCode = + | WebhookUrlProblem + | "timeout" + | "connection_failed" + | "redirect" + | "http_error"; + +export type WebhookSendResult = + | { ok: true; responseStatus: number; durationMs: number } + | { + ok: false; + responseStatus: number | null; + errorCode: WebhookErrorCode; + durationMs: number; + }; + +export type WebhookGuards = { + allowPrivate?: boolean; + isBlocked?: (url: string) => Promise; + request?: (url: string, init: RequestInit) => Promise; + timeoutMs?: number; + now?: () => number; +}; + +// A receiver on localhost is how a webhook handler is written, so a local +// build may call plain http and private addresses. Every production build, +// Cap's own or a self-hosted one, only calls public https hosts. +export const allowsPrivateWebhookTargets = () => NODE_ENV !== "production"; + +const URL_PROBLEM_MESSAGES: Record = { + invalid_url: "The URL is not valid", + https_required: "The URL must start with https://", + credentials_in_url: "The URL must not contain a username or password", + unsupported_port: "The URL must use the default https port", + private_address: "The URL must point to a public address", +}; + +export const webhookUrlProblemMessage = (problem: WebhookUrlProblem) => + URL_PROBLEM_MESSAGES[problem]; + +export function webhookUrlProblem( + value: string, + allowPrivate: boolean = allowsPrivateWebhookTargets(), +): WebhookUrlProblem | null { + const problem = publicMediaUrlProblem(value); + if (problem === "unsupported_scheme") return "https_required"; + if (problem === "invalid_url" || problem === "credentials_in_url") { + return problem; + } + if (allowPrivate) return null; + if (new URL(value).protocol !== "https:") return "https_required"; + return problem; +} + +export async function checkWebhookUrl( + value: string, + guards: Pick = {}, +): Promise { + const allowPrivate = guards.allowPrivate ?? allowsPrivateWebhookTargets(); + const problem = webhookUrlProblem(value, allowPrivate); + if (problem || allowPrivate) return problem; + return (await (guards.isBlocked ?? isBlockedS3Endpoint)(value)) + ? "private_address" + : null; +} + +const requestAnyAddress = (url: string, init: RequestInit) => + fetch(url, { ...init, redirect: "manual" }); + +const refusedPrivateAddress = (error: unknown) => + error instanceof Error && /private address/i.test(error.message); + +export async function sendDeveloperWebhook( + input: { + url: string; + secret: string; + eventId: string; + eventType: string; + body: string; + }, + guards: WebhookGuards = {}, +): Promise { + const now = guards.now ?? Date.now; + const started = now(); + const failed = ( + errorCode: WebhookErrorCode, + responseStatus: number | null = null, + ): WebhookSendResult => ({ + ok: false, + responseStatus, + errorCode, + durationMs: now() - started, + }); + + const allowPrivate = guards.allowPrivate ?? allowsPrivateWebhookTargets(); + const problem = await checkWebhookUrl(input.url, { + allowPrivate, + isBlocked: guards.isBlocked, + }); + if (problem) return failed(problem); + + const request = + guards.request ?? (allowPrivate ? requestAnyAddress : requestPublicOnly); + const abort = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + abort.abort(); + }, guards.timeoutMs ?? WEBHOOK_TIMEOUT_MS); + + try { + const response = await request(input.url, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "Cap-Webhooks/1.0", + [WEBHOOK_SIGNATURE_HEADER]: signWebhookBody( + input.secret, + input.body, + Math.floor(started / 1000), + ), + [WEBHOOK_EVENT_ID_HEADER]: input.eventId, + [WEBHOOK_EVENT_TYPE_HEADER]: input.eventType, + }, + body: input.body, + signal: abort.signal, + }); + const { status } = response; + await response.body?.cancel().catch(() => {}); + if (status >= 200 && status < 300) { + return { ok: true, responseStatus: status, durationMs: now() - started }; + } + return failed( + status >= 300 && status < 400 ? "redirect" : "http_error", + status, + ); + } catch (error) { + if (timedOut) return failed("timeout"); + return failed( + refusedPrivateAddress(error) ? "private_address" : "connection_failed", + ); + } finally { + clearTimeout(timer); + } +} + +const keepUpdatedAt = { + updatedAt: sql`${developerWebhookEndpoints.updatedAt}`, +}; + +export async function recordWebhookDelivery( + endpointId: string, + result: WebhookSendResult, + options: { countsTowardStreak: boolean; now?: Date }, +): Promise { + const now = options.now ?? new Date(); + const last = { + lastDeliveryAt: now, + lastDeliveryStatus: result.ok + ? ("succeeded" as const) + : ("failed" as const), + lastResponseStatus: result.responseStatus, + lastErrorCode: result.ok ? null : result.errorCode, + ...keepUpdatedAt, + }; + const streak = !options.countsTowardStreak + ? {} + : result.ok + ? { consecutiveFailures: 0, failingSince: null } + : { + consecutiveFailures: sql`${developerWebhookEndpoints.consecutiveFailures} + ${1}`, + }; + await db() + .update(developerWebhookEndpoints) + .set({ ...last, ...streak }) + .where(eq(developerWebhookEndpoints.id, endpointId)); + + if (result.ok || !options.countsTowardStreak) return; + await db() + .update(developerWebhookEndpoints) + .set({ failingSince: now, ...keepUpdatedAt }) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + isNull(developerWebhookEndpoints.failingSince), + ), + ); + await db() + .update(developerWebhookEndpoints) + .set({ + enabled: false, + disabledReason: WEBHOOK_DISABLED_BY_FAILURES, + ...keepUpdatedAt, + }) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + eq(developerWebhookEndpoints.enabled, true), + gte( + developerWebhookEndpoints.consecutiveFailures, + WEBHOOK_DISABLE_AFTER_FAILURES, + ), + lte( + developerWebhookEndpoints.failingSince, + new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS), + ), + ), + ); +} + +async function findDeliverableEndpoint(endpointId: string) { + const [endpoint] = await db() + .select({ + id: developerWebhookEndpoints.id, + url: developerWebhookEndpoints.url, + encryptedSecret: developerWebhookEndpoints.encryptedSecret, + events: developerWebhookEndpoints.events, + enabled: developerWebhookEndpoints.enabled, + }) + .from(developerWebhookEndpoints) + .innerJoin( + developerApps, + and( + eq(developerWebhookEndpoints.appId, developerApps.id), + isNull(developerApps.deletedAt), + ), + ) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ) + .limit(1); + return endpoint ?? null; +} + +export async function deliverDeveloperWebhookAttempt( + job: WebhookDeliveryJob, + guards: WebhookGuards = {}, +): Promise { + const endpoint = await findDeliverableEndpoint(job.endpointId); + if ( + !endpoint || + !endpoint.enabled || + !endpoint.events.includes(job.eventType) + ) { + return "dropped"; + } + const result = await sendDeveloperWebhook( + { + url: endpoint.url, + secret: await decrypt(endpoint.encryptedSecret), + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + guards, + ); + await recordWebhookDelivery(endpoint.id, result, { + countsTowardStreak: true, + }); + return result.ok ? "delivered" : "retry"; +} diff --git a/apps/web/lib/developer-webhook-dispatch.ts b/apps/web/lib/developer-webhook-dispatch.ts new file mode 100644 index 00000000000..fc2393a8945 --- /dev/null +++ b/apps/web/lib/developer-webhook-dispatch.ts @@ -0,0 +1,116 @@ +import { db } from "@cap/database"; +import { + developerVideos, + developerWebhookEndpoints, +} from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { after } from "next/server"; +import { start } from "workflow/api"; +import { serializeDeveloperVideo } from "@/lib/developer-video-api"; +import { + developerVideoEventType, + type WebhookEventType, +} from "@/lib/developer-webhook-events"; +import { developerVideoEventId } from "@/lib/developer-webhook-signature"; +import { deliverDeveloperWebhookWorkflow } from "@/workflows/deliver-developer-webhook"; + +const STATUS_OF_EVENT: Record = { + "video.ready": "ready", + "video.errored": "errored", +}; + +async function subscribedEndpointIds( + appId: string, + type: WebhookEventType, +): Promise { + const endpoints = await db() + .select({ + id: developerWebhookEndpoints.id, + events: developerWebhookEndpoints.events, + }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + eq(developerWebhookEndpoints.enabled, true), + ), + ); + return endpoints + .filter( + (endpoint) => + Array.isArray(endpoint.events) && endpoint.events.includes(type), + ) + .map((endpoint) => endpoint.id); +} + +// The caller is still inside the transaction that changes the status. The +// locking read waits for that transaction to end, so the event is built from +// what was committed, and a change that was rolled back sends nothing. +async function committedVideo(videoId: string) { + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .for("update"); + return video ?? null; +} + +export async function enqueueDeveloperVideoEvent( + target: { id: string; appId: string }, + type: WebhookEventType, +): Promise { + const endpointIds = await subscribedEndpointIds(target.appId, type); + if (endpointIds.length === 0) return 0; + + const video = await committedVideo(target.id); + if (!video || video.deletedAt || video.status !== STATUS_OF_EVENT[type]) { + return 0; + } + + const eventId = developerVideoEventId(video.id, type); + const body = JSON.stringify({ + id: eventId, + type, + createdAt: new Date().toISOString(), + data: serializeDeveloperVideo(video), + }); + const started = await Promise.allSettled( + endpointIds.map((endpointId) => + start(deliverDeveloperWebhookWorkflow, [ + { endpointId, eventId, eventType: type, body }, + ]), + ), + ); + const failures = started.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + console.error("[developer-webhook] Failed to queue deliveries", { + videoId: video.id, + eventId, + failed: failures.length, + }); + } + return started.length - failures.length; +} + +// Never awaited: a video's status change must not wait for, or fail on, +// anything to do with webhooks. +export function announceDeveloperVideoStatus( + video: { id: string; appId: string; status: string }, + updates: { status?: string }, +): void { + const type = developerVideoEventType(video.status, updates.status); + if (!type) return; + const queued = enqueueDeveloperVideoEvent(video, type).catch((error) => { + console.error("[developer-webhook] Failed to queue an event", { + videoId: video.id, + type, + message: error instanceof Error ? error.message : String(error), + }); + }); + try { + after(queued); + } catch { + void queued; + } +} diff --git a/apps/web/lib/developer-webhook-events.ts b/apps/web/lib/developer-webhook-events.ts new file mode 100644 index 00000000000..76d73fca7be --- /dev/null +++ b/apps/web/lib/developer-webhook-events.ts @@ -0,0 +1,51 @@ +export const WEBHOOK_EVENT_TYPES = ["video.ready", "video.errored"] as const; +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +export const WEBHOOK_TEST_EVENT_TYPE = "webhook.test"; + +export const MAX_WEBHOOK_ENDPOINTS_PER_APP = 5; +export const WEBHOOK_TIMEOUT_MS = 10_000; + +// Eight attempts in all: the first straight away, then one after each of +// these gaps, which add up to 23 hours 36 minutes. +export const WEBHOOK_RETRY_DELAYS_SECONDS = [ + 60, + 5 * 60, + 30 * 60, + 2 * 60 * 60, + 5 * 60 * 60, + 8 * 60 * 60, + 8 * 60 * 60, +] as const; +export const WEBHOOK_MAX_ATTEMPTS = WEBHOOK_RETRY_DELAYS_SECONDS.length + 1; + +export const WEBHOOK_DISABLE_AFTER_FAILURES = 20; +export const WEBHOOK_DISABLE_AFTER_MS = 5 * 24 * 60 * 60 * 1000; +export const WEBHOOK_DISABLED_BY_FAILURES = "delivery_failures"; + +export type WebhookDeliveryOutcome = "delivered" | "retry" | "dropped"; + +export interface WebhookDeliveryJob { + endpointId: string; + eventId: string; + eventType: string; + body: string; +} + +export function webhookRetryDelaySeconds(attempt: number): number | null { + return WEBHOOK_RETRY_DELAYS_SECONDS[attempt - 1] ?? null; +} + +export function isWebhookEventType(value: string): value is WebhookEventType { + return WEBHOOK_EVENT_TYPES.some((type) => type === value); +} + +export function developerVideoEventType( + previous: string, + next: string | undefined, +): WebhookEventType | null { + if (next === undefined || next === previous) return null; + if (next === "ready") return "video.ready"; + if (next === "errored") return "video.errored"; + return null; +} diff --git a/apps/web/lib/developer-webhook-signature.ts b/apps/web/lib/developer-webhook-signature.ts new file mode 100644 index 00000000000..69082862f06 --- /dev/null +++ b/apps/web/lib/developer-webhook-signature.ts @@ -0,0 +1,74 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import type { WebhookEventType } from "@/lib/developer-webhook-events"; + +export const WEBHOOK_SECRET_PREFIX = "cws_"; +export const WEBHOOK_SIGNATURE_HEADER = "Cap-Signature"; +export const WEBHOOK_EVENT_ID_HEADER = "Cap-Event-Id"; +export const WEBHOOK_EVENT_TYPE_HEADER = "Cap-Event-Type"; +export const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; + +const EVENT_ID_PREFIX = "evt_"; + +export const createWebhookSecret = () => + `${WEBHOOK_SECRET_PREFIX}${nanoIdLong()}`; + +export const createWebhookTestEventId = () => + `${EVENT_ID_PREFIX}test_${nanoId()}`; + +// A video reaches each of these states once, so the same video and type +// always name the same event. A repeated send carries the id the receiver +// has already seen. +export function developerVideoEventId( + videoId: string, + type: WebhookEventType, +): string { + const digest = createHash("sha256") + .update(`${type}:${videoId}`) + .digest("hex"); + return `${EVENT_ID_PREFIX}${digest.slice(0, 24)}`; +} + +function signature(secret: string, timestamp: number, body: string): string { + return createHmac("sha256", secret) + .update(`${timestamp}.${body}`) + .digest("hex"); +} + +export function signWebhookBody( + secret: string, + body: string, + timestamp: number, +): string { + return `t=${timestamp},v1=${signature(secret, timestamp, body)}`; +} + +export function verifyWebhookSignature(input: { + secret: string; + body: string; + header: string | null | undefined; + now?: number; + toleranceSeconds?: number; +}): boolean { + if (!input.header) return false; + const fields = new Map( + input.header.split(",").map((part) => { + const [name, ...rest] = part.split("="); + return [name?.trim() ?? "", rest.join("=").trim()] as const; + }), + ); + const rawTimestamp = fields.get("t") ?? ""; + const supplied = fields.get("v1") ?? ""; + if (!/^\d{1,12}$/.test(rawTimestamp) || !/^[0-9a-f]{64}$/.test(supplied)) { + return false; + } + const timestamp = Number(rawTimestamp); + const nowSeconds = Math.floor((input.now ?? Date.now()) / 1000); + const tolerance = + input.toleranceSeconds ?? WEBHOOK_SIGNATURE_TOLERANCE_SECONDS; + if (Math.abs(nowSeconds - timestamp) > tolerance) return false; + return timingSafeEqual( + Buffer.from(supplied, "hex"), + Buffer.from(signature(input.secret, timestamp, input.body), "hex"), + ); +} diff --git a/apps/web/lib/developer-webhooks.ts b/apps/web/lib/developer-webhooks.ts new file mode 100644 index 00000000000..3fb6c180e93 --- /dev/null +++ b/apps/web/lib/developer-webhooks.ts @@ -0,0 +1,294 @@ +import { db } from "@cap/database"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { + checkWebhookUrl, + recordWebhookDelivery, + sendDeveloperWebhook, + type WebhookGuards, + type WebhookSendResult, + type WebhookUrlProblem, + webhookUrlProblemMessage, +} from "@/lib/developer-webhook-delivery"; +import { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_EVENT_TYPES, + WEBHOOK_TEST_EVENT_TYPE, + type WebhookEventType, +} from "@/lib/developer-webhook-events"; +import { + createWebhookSecret, + createWebhookTestEventId, +} from "@/lib/developer-webhook-signature"; + +export type WebhookEndpointRow = typeof developerWebhookEndpoints.$inferSelect; + +export type WebhookFailure = { + ok: false; + status: 400 | 404 | 409; + code: string; + message: string; + hint: string; +}; + +const SECRET_PREFIX_LENGTH = 12; + +const failure = ( + status: WebhookFailure["status"], + code: string, + message: string, + hint: string, +): WebhookFailure => ({ ok: false, status, code, message, hint }); + +const notFound = () => + failure( + 404, + "webhook_not_found", + "Webhook endpoint not found", + "Check the endpoint ID and that it belongs to this app. Deleted endpoints are not returned.", + ); + +const URL_HINTS: Record = { + invalid_url: + "Send a full URL of at most 2,048 characters, such as https://example.com/webhooks/cap.", + https_required: "Webhooks are only sent over https.", + credentials_in_url: + "Remove the username and password. Verify each request with its Cap-Signature header instead.", + unsupported_port: "Serve the endpoint on port 443 and leave the port out.", + private_address: + "The host must resolve to a public internet address. Private, loopback, link-local and reserved addresses are refused.", +}; + +const invalidUrl = (problem: WebhookUrlProblem) => + failure( + 400, + "invalid_webhook_url", + webhookUrlProblemMessage(problem), + URL_HINTS[problem], + ); + +export function serializeWebhookEndpoint(endpoint: WebhookEndpointRow) { + return { + id: endpoint.id, + url: endpoint.url, + events: endpoint.events, + enabled: endpoint.enabled, + disabledReason: endpoint.disabledReason, + secretPrefix: endpoint.secretPrefix, + lastDeliveryAt: endpoint.lastDeliveryAt, + lastDeliveryStatus: endpoint.lastDeliveryStatus, + lastResponseStatus: endpoint.lastResponseStatus, + lastErrorCode: endpoint.lastErrorCode, + consecutiveFailures: endpoint.consecutiveFailures, + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }; +} + +const liveEndpoint = (appId: string, endpointId: string) => + and( + eq(developerWebhookEndpoints.id, endpointId), + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ); + +export async function getWebhookEndpoint( + appId: string, + endpointId: string, +): Promise { + const [endpoint] = await db() + .select() + .from(developerWebhookEndpoints) + .where(liveEndpoint(appId, endpointId)) + .limit(1); + return endpoint ?? null; +} + +export async function listWebhookEndpoints( + appId: string, + page: { limit: number; offset: number }, +): Promise<{ endpoints: WebhookEndpointRow[]; hasMore: boolean }> { + const endpoints = await db() + .select() + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ) + .orderBy( + desc(developerWebhookEndpoints.createdAt), + desc(developerWebhookEndpoints.id), + ) + .limit(page.limit + 1) + .offset(page.offset); + return { + endpoints: endpoints.slice(0, page.limit), + hasMore: endpoints.length > page.limit, + }; +} + +export async function createWebhookEndpoint( + appId: string, + input: { url: string; events?: WebhookEventType[]; enabled?: boolean }, + guards: Pick = {}, +): Promise< + { ok: true; endpoint: WebhookEndpointRow; secret: string } | WebhookFailure +> { + const problem = await checkWebhookUrl(input.url, guards); + if (problem) return invalidUrl(problem); + + const id = nanoId(); + const secret = createWebhookSecret(); + const encryptedSecret = await encrypt(secret); + + const created = await db().transaction(async (tx) => { + // The app row is the lock, so two requests at once cannot both pass the + // count and leave the app over the limit. + await tx + .select({ id: developerApps.id }) + .from(developerApps) + .where(eq(developerApps.id, appId)) + .for("update"); + const [existing] = await tx + .select({ total: count() }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ); + if ((existing?.total ?? 0) >= MAX_WEBHOOK_ENDPOINTS_PER_APP) return false; + await tx.insert(developerWebhookEndpoints).values({ + id, + appId, + url: new URL(input.url).href, + secretPrefix: secret.slice(0, SECRET_PREFIX_LENGTH), + encryptedSecret, + events: [...(input.events ?? WEBHOOK_EVENT_TYPES)], + enabled: input.enabled ?? true, + }); + return true; + }); + if (!created) { + return failure( + 409, + "webhook_limit_reached", + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} webhook endpoints`, + "Delete an endpoint you no longer use, then create this one again.", + ); + } + + const endpoint = await getWebhookEndpoint(appId, id); + if (!endpoint) return notFound(); + return { ok: true, endpoint, secret }; +} + +export async function updateWebhookEndpoint( + appId: string, + endpointId: string, + input: { url?: string; events?: WebhookEventType[]; enabled?: boolean }, + guards: Pick = {}, +): Promise<{ ok: true; endpoint: WebhookEndpointRow } | WebhookFailure> { + const current = await getWebhookEndpoint(appId, endpointId); + if (!current) return notFound(); + + if (input.url !== undefined) { + const problem = await checkWebhookUrl(input.url, guards); + if (problem) return invalidUrl(problem); + } + const url = input.url === undefined ? undefined : new URL(input.url).href; + const restarts = + input.enabled === true || (url !== undefined && url !== current.url); + + await db() + .update(developerWebhookEndpoints) + .set({ + ...(url === undefined ? {} : { url }), + ...(input.events === undefined ? {} : { events: input.events }), + ...(input.enabled === undefined ? {} : { enabled: input.enabled }), + ...(input.enabled === true ? { disabledReason: null } : {}), + ...(restarts ? { consecutiveFailures: 0, failingSince: null } : {}), + }) + .where(liveEndpoint(appId, endpointId)); + + const endpoint = await getWebhookEndpoint(appId, endpointId); + return endpoint ? { ok: true, endpoint } : notFound(); +} + +export async function deleteWebhookEndpoint( + appId: string, + endpointId: string, +): Promise<{ ok: true } | WebhookFailure> { + const [endpoint] = await db() + .select({ deletedAt: developerWebhookEndpoints.deletedAt }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + eq(developerWebhookEndpoints.appId, appId), + ), + ) + .limit(1); + if (!endpoint) return notFound(); + if (!endpoint.deletedAt) { + await db() + .update(developerWebhookEndpoints) + .set({ deletedAt: new Date(), enabled: false }) + .where(liveEndpoint(appId, endpointId)); + } + return { ok: true }; +} + +export function serializeWebhookTestResult( + eventId: string, + result: WebhookSendResult, +) { + return { + eventId, + type: WEBHOOK_TEST_EVENT_TYPE, + delivered: result.ok, + responseStatus: result.responseStatus, + errorCode: result.ok ? null : result.errorCode, + durationMs: result.durationMs, + }; +} + +// One attempt, sent while the caller waits and never retried. It records the +// last delivery but leaves the failure streak alone, so testing an endpoint +// that is not up yet cannot count towards disabling it. +export async function sendWebhookTestEvent( + appId: string, + endpointId: string, + guards: WebhookGuards = {}, +): Promise< + { ok: true; eventId: string; result: WebhookSendResult } | WebhookFailure +> { + const endpoint = await getWebhookEndpoint(appId, endpointId); + if (!endpoint) return notFound(); + + const eventId = createWebhookTestEventId(); + const result = await sendDeveloperWebhook( + { + url: endpoint.url, + secret: await decrypt(endpoint.encryptedSecret), + eventId, + eventType: WEBHOOK_TEST_EVENT_TYPE, + body: JSON.stringify({ + id: eventId, + type: WEBHOOK_TEST_EVENT_TYPE, + createdAt: new Date().toISOString(), + data: { webhookId: endpoint.id }, + }), + }, + guards, + ); + await recordWebhookDelivery(endpoint.id, result, { + countsTowardStreak: false, + }); + return { ok: true, eventId, result }; +} diff --git a/apps/web/workflows/deliver-developer-webhook.ts b/apps/web/workflows/deliver-developer-webhook.ts new file mode 100644 index 00000000000..20854bbb3ff --- /dev/null +++ b/apps/web/workflows/deliver-developer-webhook.ts @@ -0,0 +1,41 @@ +import { sleep } from "workflow"; +import { deliverDeveloperWebhookAttempt } from "@/lib/developer-webhook-delivery"; +import { + type WebhookDeliveryJob, + type WebhookDeliveryOutcome, + webhookRetryDelaySeconds, +} from "@/lib/developer-webhook-events"; + +export async function deliverDeveloperWebhookWorkflow( + job: WebhookDeliveryJob, +): Promise<{ outcome: WebhookDeliveryOutcome | "gave_up"; attempts: number }> { + "use workflow"; + + for (let attempt = 1; ; attempt++) { + const outcome = await attemptDelivery(job); + if (outcome !== "retry") return { outcome, attempts: attempt }; + const delay = webhookRetryDelaySeconds(attempt); + if (delay === null) return { outcome: "gave_up", attempts: attempt }; + await sleep(`${delay}s`); + } +} + +async function attemptDelivery( + job: WebhookDeliveryJob, +): Promise { + "use step"; + + // A thrown step is run again by the engine straight away and then fails + // the run. Returning "retry" keeps a database blip on the same schedule + // as a failed delivery. + try { + return await deliverDeveloperWebhookAttempt(job); + } catch (error) { + console.error("[developer-webhook] Delivery attempt failed to run", { + endpointId: job.endpointId, + eventId: job.eventId, + message: error instanceof Error ? error.message : String(error), + }); + return "retry"; + } +} From 24e9d4a343b73029bd8b492a77f1f5cf71443eb5 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:59:07 -0500 Subject: [PATCH 04/13] docs(web): document webhooks in the API guide and the OpenAPI description --- apps/web/__tests__/unit/agent-docs.test.ts | 3 +- .../unit/developer-webhook-docs.test.ts | 212 ++++++++++ apps/web/content/docs/api/rest-api.mdx | 24 ++ apps/web/content/docs/api/webhooks.mdx | 287 ++++++++++++- apps/web/lib/developer-openapi.ts | 398 ++++++++++++++++++ 5 files changed, 912 insertions(+), 12 deletions(-) create mode 100644 apps/web/__tests__/unit/developer-webhook-docs.test.ts diff --git a/apps/web/__tests__/unit/agent-docs.test.ts b/apps/web/__tests__/unit/agent-docs.test.ts index 38d5f24f4a1..6691ae8b0a4 100644 --- a/apps/web/__tests__/unit/agent-docs.test.ts +++ b/apps/web/__tests__/unit/agent-docs.test.ts @@ -145,7 +145,8 @@ describe("Cap for Agents docs", () => { ); const webhooks = getDocBySlug("api/webhooks")?.content ?? ""; - expect(webhooks).toContain( + expect(webhooks).toContain("POST /api/developer/v1/webhooks"); + expect(webhooks).not.toContain( "does not currently expose a documented public webhook subscription API", ); }); diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts new file mode 100644 index 00000000000..5a0c463b9d1 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -0,0 +1,212 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileMDX } from "next-mdx-remote/rsc"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); +const { developerOpenApiSpec } = await import("@/lib/developer-openapi"); +const { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_EVENT_TYPES, + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_RETRY_DELAYS_SECONDS, + WEBHOOK_TEST_EVENT_TYPE, + WEBHOOK_TIMEOUT_MS, +} = await import("@/lib/developer-webhook-events"); +const { signWebhookBody, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS } = await import( + "@/lib/developer-webhook-signature" +); +const { extractHeadings } = await import("@/utils/docs"); + +const read = (name: string) => + readFileSync(join(process.cwd(), "content/docs/api", name), "utf8"); +const guide = read("webhooks.mdx"); +const rest = read("rest-api.mdx"); +const spec = developerOpenApiSpec("https://cap.so"); + +const gap = (seconds: number) => + seconds % 3_600 === 0 + ? `${seconds / 3_600} hour${seconds === 3_600 ? "" : "s"}` + : `${seconds / 60} minute${seconds === 60 ? "" : "s"}`; + +describe("the webhooks guide", () => { + it("compiles as MDX and links only to headings that exist", async () => { + await expect( + compileMDX({ source: guide, components: {} }), + ).resolves.toBeDefined(); + const anchors = new Map([ + ["/docs/api/rest-api", extractHeadings(rest).map((h) => h.slug)], + ["", extractHeadings(guide).map((h) => h.slug)], + ]); + const links = [ + ...guide.matchAll(/\]\((\/docs\/api\/rest-api)?#([\w-]+)\)/g), + ]; + expect(links.length).toBeGreaterThan(4); + for (const [, page = "", anchor] of links) { + expect(anchors.get(page), `${page}#${anchor}`).toContain(anchor); + } + expect(rest).toContain("](/docs/api/webhooks)"); + expect(extractHeadings(rest).map((h) => h.slug)).toContain("webhooks"); + }); + + it("names every event type, header and limit the code uses", () => { + for (const type of [...WEBHOOK_EVENT_TYPES, WEBHOOK_TEST_EVENT_TYPE]) { + expect(guide).toContain(`\`${type}\``); + } + for (const name of ["Cap-Signature", "Cap-Event-Id", "Cap-Event-Type"]) { + expect(guide).toContain(name); + } + expect(guide).toContain( + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints`, + ); + expect(rest).toContain( + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints`, + ); + expect(guide).toContain(`within ${WEBHOOK_TIMEOUT_MS / 1000} seconds`); + expect(WEBHOOK_SIGNATURE_TOLERANCE_SECONDS).toBe(300); + expect(guide).toContain("more than five minutes"); + expect(guide).toContain("> 300"); + }); + + it("states the retry schedule the workflow follows", () => { + expect(guide).toContain( + `That is ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day`, + ); + const rows = [...guide.matchAll(/^\| (\d) \| (.+) \|$/gm)].map( + ([, attempt, sent]) => [Number(attempt), sent] as const, + ); + expect(rows.map(([attempt]) => attempt)).toEqual( + Array.from({ length: WEBHOOK_MAX_ATTEMPTS }, (_, index) => index + 1), + ); + expect(rows[0]?.[1]).toBe("Straight away"); + WEBHOOK_RETRY_DELAYS_SECONDS.forEach((seconds, index) => { + expect(rows[index + 1]?.[1]).toContain(gap(seconds)); + }); + expect(guide).toContain( + `fails ${WEBHOOK_DISABLE_AFTER_FAILURES} attempts in a row over five days`, + ); + expect(WEBHOOK_DISABLE_AFTER_MS).toBe(5 * 24 * 60 * 60 * 1000); + }); + + it("gives a Node example that accepts Cap's signature and rejects a forged one", async () => { + const example = guide.match(/```js\n([\s\S]*?verifyCapWebhook[\s\S]*?)```/); + expect(example).not.toBeNull(); + const folder = mkdtempSync(join(tmpdir(), "cap-webhook-doc-")); + try { + writeFileSync(join(folder, "verify.mjs"), example?.[1] ?? ""); + const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; + const body = JSON.stringify({ id: "evt_1", type: "video.ready" }); + const now = Math.floor(Date.now() / 1000); + const header = signWebhookBody(secret, body, now); + const cases = [ + [body, header, secret], + [`${body} `, header, secret], + [body, header, `${secret}x`], + [body, null, secret], + [body, "t=1,v1=zz", secret], + [body, signWebhookBody(secret, body, now - 301), secret], + ]; + writeFileSync(join(folder, "cases.json"), JSON.stringify(cases)); + writeFileSync( + join(folder, "run.mjs"), + [ + 'import { readFileSync } from "node:fs";', + 'import { verifyCapWebhook } from "./verify.mjs";', + 'const cases = JSON.parse(readFileSync(new URL("./cases.json", import.meta.url), "utf8"));', + "console.log(JSON.stringify(cases.map(([body, header, secret]) => verifyCapWebhook(body, header ?? undefined, secret))));", + ].join("\n"), + ); + const output = execFileSync(process.execPath, [join(folder, "run.mjs")], { + encoding: "utf8", + }); + expect(JSON.parse(output)).toEqual([ + true, + false, + false, + false, + false, + false, + ]); + } finally { + rmSync(folder, { recursive: true, force: true }); + } + }); + + it("teaches the REST API only", () => { + expect(guide).not.toMatch(/\bcap (developers|caps|webhooks)\b/); + expect(guide).not.toMatch(/\bMCP\b/); + expect(guide).not.toContain("—"); + }); +}); + +describe("webhooks in the OpenAPI description", () => { + const paths = spec.paths as Record>; + + it("describes every webhook route the API serves, and no others", () => { + const served = [ + ...new Set( + app.routes + .filter((route) => route.method !== "ALL") + .map( + (route) => + `${route.method} /api/developer/v1/webhooks${ + route.path === "/" ? "" : route.path.replace(/:(\w+)/g, "{$1}") + }`, + ), + ), + ].sort(); + const described = Object.entries(paths) + .filter(([path]) => path.startsWith("/api/developer/v1/webhooks")) + .flatMap(([path, item]) => + Object.keys(item).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort(); + expect(described).toEqual(served); + expect(described).toHaveLength(6); + }); + + it("returns the signing secret from create only", () => { + const schemas = spec.components.schemas as Record< + string, + { properties?: object } + >; + expect( + Object.keys(schemas.WebhookEndpoint?.properties ?? {}), + ).not.toContain("secret"); + const text = (operation: unknown) => JSON.stringify(operation); + expect(text(paths["/api/developer/v1/webhooks"]?.post)).toContain( + "WebhookEndpointWithSecret", + ); + for (const operation of [ + paths["/api/developer/v1/webhooks"]?.get, + paths["/api/developer/v1/webhooks/{id}"]?.get, + paths["/api/developer/v1/webhooks/{id}"]?.patch, + ]) { + expect(text(operation)).not.toContain("WebhookEndpointWithSecret"); + } + }); + + it("describes the request Cap sends for each event", () => { + const outbound = spec.webhooks as Record; + expect(Object.keys(outbound).sort()).toEqual( + [...WEBHOOK_EVENT_TYPES, WEBHOOK_TEST_EVENT_TYPE].sort(), + ); + for (const delivery of Object.values(outbound)) { + const text = JSON.stringify(delivery.post); + expect(text).toContain("Cap-Signature"); + expect(text).toContain("Cap-Event-Id"); + expect(text).toContain("Cap-Event-Type"); + } + }); +}); diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index fc0b3d67472..76a41412326 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1034,6 +1034,30 @@ Rows written in the same second have the same `createdAt`. Within a page they ar The console's Billing page shows the same rows and exports them all as CSV. +### Webhooks + +Have Cap call your server when a video is ready or has failed, instead of polling for it. Register an https URL for the app and Cap sends it a signed `POST` request for each event. The [Webhooks](/docs/api/webhooks) guide covers the events, the request Cap sends, how to verify its signature, and retries. + +| Request | What it does | +|---------|--------------| +| `POST /api/developer/v1/webhooks` | Create an endpoint. The response carries its signing secret, once | +| `GET /api/developer/v1/webhooks` | List the app's endpoints with `limit` and `offset` | +| `GET /api/developer/v1/webhooks/:id` | Read one endpoint and how Cap's last call to it went | +| `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled` | +| `DELETE /api/developer/v1/webhooks/:id` | Delete an endpoint | +| `POST /api/developer/v1/webhooks/:id/test` | Send a test event and report what the endpoint answered | + +**Example request:** + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/webhooks/cap", "events": ["video.ready", "video.errored"]}' +``` + +The events are `video.ready` and `video.errored`, and each carries the video as [Get Video](/docs/api/rest-api#get-video) returns it. An app can have 5 endpoints. + --- ## SDK API Endpoints diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 5d0e2adcf97..868baf1c50f 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -1,22 +1,287 @@ --- title: "Webhooks" -summary: "Current webhook availability for public Cap integrations" +summary: "Have Cap call your server when a video is ready or has failed" tags: "API, Integration" --- -Cap does not currently expose a documented public webhook subscription API for customer applications. +A webhook tells your server the moment a video finishes processing, so you do not have to poll for it. You register an https URL for your app, and Cap sends it a signed `POST` request when a video becomes ready or fails. -The repository contains internal webhook endpoints for services such as Stripe and the media-processing pipeline. Those are implementation endpoints, not a stable customer webhook product, and integrations should not call them. +Webhooks belong to an app in [Cap Infra](/dashboard/infra) and are managed with the [REST API](/docs/api/rest-api#webhooks) and the app's secret key (`csk_`). -## Poll supported API state +## Events -Use the [REST API](/docs/api/rest-api) to read the supported developer state. For an uploaded video, poll its status endpoint until `ready` is `true` before presenting it as processed. +| Type | Sent when | +|------|-----------| +| `video.ready` | A video has finished processing and can be played | +| `video.errored` | A video could not be processed. `errorReason` says why | +| `webhook.test` | You asked for a [test event](#send-a-test-event) | -Keep polling bounded: +A video sends `video.ready` or `video.errored` once. Handlers should ignore a type they do not know, so a new event type never breaks them. -- Use backoff instead of a tight loop. -- Stop on terminal success or error. -- Store the last observed state locally. -- Respect HTTP errors and any retry guidance returned by the API. +On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. No second event is sent when it arrives. Read it with [Get Video](/docs/api/rest-api#get-video). -Do not build against an undocumented route or assume that an internal webhook payload will remain stable. +## Create an endpoint + +``` +POST /api/developer/v1/webhooks +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | string | Yes | Where Cap sends events. See the rules below | +| `events` | string[] | No | The event types to send: `video.ready`, `video.errored` or both. Defaults to both | +| `enabled` | boolean | No | Defaults to `true` | + +A field the request does not know returns `400` with code `invalid_request`. + +**Example request:** + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/webhooks/cap"}' +``` + +**Example response:** + +```json +{ + "data": { + "id": "7k2m9x4q1p3v5wd", + "url": "https://example.com/webhooks/cap", + "events": ["video.ready", "video.errored"], + "enabled": true, + "disabledReason": null, + "secretPrefix": "cws_7m2k9x4q", + "lastDeliveryAt": null, + "lastDeliveryStatus": null, + "lastResponseStatus": null, + "lastErrorCode": null, + "consecutiveFailures": 0, + "createdAt": "2026-11-13T00:19:06.000Z", + "updatedAt": "2026-11-13T00:19:06.000Z", + "secret": "cws_your_signing_secret_here" + } +} +``` + +`secret` is the endpoint's signing secret. It is returned once, in this response, and never again. Store it with your other secrets. If you lose it, delete the endpoint and create a new one. + +The URL must: + +- use `https` on the default port +- be at most 2,048 characters +- carry no username or password +- name a host that resolves to a public internet address. Private, loopback, link-local and reserved addresses are refused + +Anything else returns `400` with code `invalid_webhook_url`. An app can have 5 endpoints. One more returns `409` with code `webhook_limit_reached`. + +## Manage endpoints + +| Request | What it does | +|---------|--------------| +| `GET /api/developer/v1/webhooks` | List the app's endpoints, newest first, with `limit` and `offset` | +| `GET /api/developer/v1/webhooks/:id` | Read one endpoint and the result of its last delivery | +| `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled`. Send at least one | +| `DELETE /api/developer/v1/webhooks/:id` | Delete the endpoint. Deliveries waiting to be retried are dropped | +| `POST /api/developer/v1/webhooks/:id/test` | [Send a test event](#send-a-test-event) | + +An ID that does not belong to the app returns `404` with code `webhook_not_found`. + +Every endpoint is returned with these fields: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | The endpoint ID | +| `url` | string | Where events are sent | +| `events` | string[] | The event types sent to it | +| `enabled` | boolean | Events are only sent to an enabled endpoint | +| `disabledReason` | string \| null | `delivery_failures` when Cap disabled the endpoint, otherwise `null` | +| `secretPrefix` | string | The first characters of the signing secret, to tell endpoints apart | +| `lastDeliveryAt` | string \| null | When Cap last called the endpoint | +| `lastDeliveryStatus` | string \| null | `succeeded` or `failed` | +| `lastResponseStatus` | number \| null | The HTTP status your endpoint last answered with, or `null` when it did not answer | +| `lastErrorCode` | string \| null | Why the last delivery failed: `http_error`, `redirect`, `timeout`, `connection_failed` or `private_address` | +| `consecutiveFailures` | number | Failed attempts since the last success | +| `createdAt` | string | Creation timestamp (ISO 8601) | +| `updatedAt` | string | When the endpoint's settings last changed | + +## The request Cap sends + +``` +POST https://example.com/webhooks/cap +Content-Type: application/json +Cap-Signature: t=1794529214,v1=5f2b6c0e... +Cap-Event-Id: evt_2ef4c0e7d14b85e004ad32a8 +Cap-Event-Type: video.ready +``` + +```json +{ + "id": "evt_2ef4c0e7d14b85e004ad32a8", + "type": "video.ready", + "createdAt": "2026-11-13T00:20:14.211Z", + "data": { + "id": "abc123def456", + "appId": "app_789", + "externalUserId": "user_42", + "name": "Product Demo", + "status": "ready", + "errorReason": null, + "duration": 124.5, + "width": 1920, + "height": 1080, + "playbackId": "Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G", + "hlsUrl": null, + "mp4Url": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/video.mp4", + "posterUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/poster.jpg" + } +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Identifies the event. Every retry, and the delivery to each of your endpoints, carries the same `id` | +| `type` | The event type | +| `createdAt` | When the event happened (ISO 8601) | +| `data` | For video events, the video exactly as [Get Video](/docs/api/rest-api#get-video) returns it. See the [Video Object Reference](/docs/api/rest-api#video-object-reference). The example above is shortened | + +`externalUserId` is the `userId` the video was created with, so the event tells you whose video it is. + +## Verify the signature + +Anyone who learns your URL can post to it, so check that each request came from Cap before you trust it. + +`Cap-Signature` holds a timestamp and a signature: `t=,v1=`. The signature is the hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with the endpoint's signing secret (the whole `cws_...` string). + +1. Read `t` and `v1` from the header. +2. Compute HMAC-SHA256 of `.` with your signing secret. +3. Compare it with `v1` using a constant-time comparison. +4. Reject the request if `t` is more than five minutes from your clock, so a captured request cannot be replayed later. + +Sign the body exactly as it arrived, byte for byte. Parsing the JSON and serialising it again changes the bytes and the signature will not match. + +**Node.js:** + +```js +import { createHmac, timingSafeEqual } from "node:crypto"; + +export function verifyCapWebhook(rawBody, signatureHeader, secret) { + const parts = Object.fromEntries( + String(signatureHeader ?? "") + .split(",") + .map((part) => part.split("=")), + ); + const timestamp = Number(parts.t); + if (!Number.isInteger(timestamp) || !parts.v1) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; + const expected = createHmac("sha256", secret) + .update(`${parts.t}.${rawBody}`) + .digest(); + const received = Buffer.from(parts.v1, "hex"); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); +} +``` + +Call it with the raw body string, the `Cap-Signature` header and your signing secret, and answer `400` when it returns `false`. + +**Python:** + +```python +import hashlib +import hmac +import time + + +def verify_cap_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: + parts = dict( + part.split("=", 1) for part in signature_header.split(",") if "=" in part + ) + try: + timestamp = int(parts["t"]) + received = parts["v1"] + except (KeyError, ValueError): + return False + if abs(time.time() - timestamp) > 300: + return False + expected = hmac.new( + secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, received) +``` + +## Respond + +Answer with any `2xx` status within 10 seconds. Do the slow work after you have answered: store the event or put it on a queue, respond, then process it. + +Any other status, a redirect, or no answer within 10 seconds counts as a failed delivery. Redirects are never followed, so register the final URL. Cap does not read or store your response body. + +## Retries + +A failed delivery is tried again with growing gaps: + +| Attempt | Sent | +|---------|------| +| 1 | Straight away | +| 2 | 1 minute after attempt 1 failed | +| 3 | 5 minutes later | +| 4 | 30 minutes later | +| 5 | 2 hours later | +| 6 | 5 hours later | +| 7 | 8 hours later | +| 8 | 8 hours later | + +That is 8 attempts over about a day. After the last one the event is dropped. + +Every attempt carries the same event `id` and the same body, with a fresh timestamp and signature. An event can reach you more than once, for example when your answer was lost on the way back, so remember the ids you have handled and skip one you have seen. Events for different videos can arrive in any order. + +An endpoint that fails 20 attempts in a row over five days or more is disabled, and its `disabledReason` becomes `delivery_failures`. Once it is fixed, turn it back on: + +```bash +curl -X PATCH https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' +``` + +Events that happened while an endpoint was disabled or deleted are not sent afterwards. Use [Get Video Status](/docs/api/rest-api#get-video-status) to catch up on a video you are waiting for. + +## Send a test event + +Check an endpoint before you rely on it: + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd/test \ + -H "Authorization: Bearer csk_your_secret_key_here" +``` + +Cap sends one signed `webhook.test` event and waits up to 10 seconds for your answer: + +```json +{ + "data": { + "eventId": "evt_test_8bs22qpm4mbqh8p", + "type": "webhook.test", + "delivered": true, + "responseStatus": 200, + "errorCode": null, + "durationMs": 42 + } +} +``` + +`delivered` is `true` when your endpoint answered with a `2xx` status. Otherwise `errorCode` says what went wrong. A test event is sent once and never retried, it works on a disabled endpoint, and a failed test does not count towards disabling the endpoint. + +The test event's body looks like any other event, with `data` holding the endpoint ID: + +```json +{ + "id": "evt_test_8bs22qpm4mbqh8p", + "type": "webhook.test", + "createdAt": "2026-11-13T00:19:06.412Z", + "data": { "webhookId": "7k2m9x4q1p3v5wd" } +} +``` diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 6cf2c765a5f..497c0f2bdb4 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -14,6 +14,13 @@ import { transcriptFailureCodesText, transcriptRequestErrorsText, } from "@/lib/developer-transcript-errors"; +import { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_EVENT_TYPES, + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_TEST_EVENT_TYPE, +} from "@/lib/developer-webhook-events"; import { INFRA_RESOLUTION_TIERS } from "@/lib/infra-pricing"; const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); @@ -205,6 +212,86 @@ const transcriptSchema = { }, }; +const webhookIdParam = { + name: "id", + in: "path", + required: true, + description: "Webhook endpoint ID returned by createWebhookEndpoint.", + schema: { type: "string" }, +}; + +const WEBHOOK_URL_RULES = + "The URL must be https on the default port, at most 2,048 characters, with no username or password, and its host must resolve to a public address. Anything else returns 400 with code invalid_webhook_url."; + +const webhookEventsProperty = { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "string", enum: [...WEBHOOK_EVENT_TYPES] }, + description: "The event types sent to this endpoint.", +}; + +const webhookDelivery = (type: string, summary: string, data: object) => ({ + post: { + operationId: `receive${type + .split(".") + .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`) + .join("")}`, + summary, + description: `Cap sends this request to every enabled endpoint subscribed to ${type}. Answer with any 2xx status within 10 seconds. Any other answer, a redirect or no answer is retried, up to ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day. Verify Cap-Signature before trusting the body, and use the event id to ignore a delivery you have already handled.`, + tags: ["Webhooks"], + security: [], + parameters: [ + { + name: "Cap-Signature", + in: "header", + required: true, + description: + 't=,v1=." keyed with the endpoint\'s signing secret>. Reject a timestamp more than five minutes old.', + schema: { type: "string" }, + }, + { + name: "Cap-Event-Id", + in: "header", + required: true, + description: "The event's id, the same value as id in the body.", + schema: { type: "string" }, + }, + { + name: "Cap-Event-Type", + in: "header", + required: true, + description: "The event's type, the same value as type in the body.", + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["id", "type", "createdAt", "data"], + properties: { + id: { + type: "string", + description: + "Identifies the event. A retry, and a delivery to another endpoint, carry the same id.", + }, + type: { type: "string", const: type }, + createdAt: { type: "string", format: "date-time" }, + data, + }, + }, + }, + }, + }, + responses: { + "2XX": { description: "The event was received." }, + }, + }, +}); + const secretAuth = [{ secretKey: [] }]; const uploadTokenAuth = [{ uploadToken: [] }]; @@ -429,7 +516,33 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ name: "SDK", description: "Upload a video's file from the browser (upload token).", }, + { + name: "Webhooks", + description: + "Have Cap call your server when a video is ready or has failed (secret key).", + }, ], + webhooks: { + "video.ready": webhookDelivery( + "video.ready", + "A video is ready to play", + ref("DeveloperVideo"), + ), + "video.errored": webhookDelivery( + "video.errored", + "A video could not be processed", + ref("DeveloperVideo"), + ), + [WEBHOOK_TEST_EVENT_TYPE]: webhookDelivery( + WEBHOOK_TEST_EVENT_TYPE, + "A test event sent by sendWebhookTestEvent", + { + type: "object", + required: ["webhookId"], + properties: { webhookId: { type: "string" } }, + }, + ), + }, paths: { "/api/developer/v1/videos": { get: { @@ -774,6 +887,122 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ }, }, }, + "/api/developer/v1/webhooks": { + get: { + operationId: "listWebhookEndpoints", + summary: "List webhook endpoints", + description: + "Lists the app's webhook endpoints, newest first. Signing secrets are never returned here. An unknown query parameter or an out-of-range limit or offset returns 400.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [ + { + name: "limit", + in: "query", + required: false, + description: "Maximum endpoints to return.", + schema: { type: "integer", minimum: 1, maximum: 100, default: 50 }, + }, + { + name: "offset", + in: "query", + required: false, + description: "Number of endpoints to skip.", + schema: { type: "integer", minimum: 0, default: 0 }, + }, + ], + responses: { + "200": json(ref("WebhookEndpointList"), "The app's endpoints."), + "400": response("BadRequest"), + ...restErrors, + }, + }, + post: { + operationId: "createWebhookEndpoint", + summary: "Create a webhook endpoint", + description: `Registers a URL that Cap calls when a video is ready or has failed. The response carries the endpoint's signing secret: store it, because it is never returned again. ${WEBHOOK_URL_RULES} An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints; one more returns 409 with code webhook_limit_reached. A field the request does not know returns 400 with code invalid_request.`, + tags: ["Webhooks"], + security: secretAuth, + requestBody: jsonBody("CreateWebhookEndpointRequest"), + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpointWithSecret")), + "The created endpoint and its signing secret.", + ), + "400": response("BadRequest"), + "409": response("WebhookLimitReached"), + "415": response("UnsupportedMediaType"), + ...restErrors, + }, + }, + }, + "/api/developer/v1/webhooks/{id}": { + get: { + operationId: "getWebhookEndpoint", + summary: "Get a webhook endpoint", + description: + "Returns one endpoint owned by the app, with the result of its last delivery.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json(dataEnvelope(ref("WebhookEndpoint")), "The endpoint."), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + patch: { + operationId: "updateWebhookEndpoint", + summary: "Update a webhook endpoint", + description: `Changes an endpoint's URL, its event types or whether it is enabled. Send at least one of them. Setting enabled to true turns an endpoint back on after Cap disabled it and clears its failure count. The signing secret does not change. ${WEBHOOK_URL_RULES}`, + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + requestBody: jsonBody("UpdateWebhookEndpointRequest"), + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpoint")), + "The updated endpoint.", + ), + "400": response("BadRequest"), + "404": response("WebhookNotFound"), + "415": response("UnsupportedMediaType"), + ...restErrors, + }, + }, + delete: { + operationId: "deleteWebhookEndpoint", + summary: "Delete a webhook endpoint", + description: + "Deletes an endpoint. Deliveries still waiting to be retried are dropped. Deleting an endpoint this app already deleted returns success, so the call is safe to repeat; an ID that never belonged to this app returns 404.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json(ref("Success"), "The endpoint was deleted."), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, + "/api/developer/v1/webhooks/{id}/test": { + post: { + operationId: "sendWebhookTestEvent", + summary: "Send a test event", + description: `Sends one signed ${WEBHOOK_TEST_EVENT_TYPE} event to the endpoint and waits up to 10 seconds for the answer. It is sent once and never retried, it works on a disabled endpoint, and a failure does not count towards disabling the endpoint. The response says whether the endpoint answered with a 2xx status. Nothing your endpoint returns in its body is read or stored.`, + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json( + dataEnvelope(ref("WebhookTestResult")), + "What the endpoint answered.", + ), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, "/api/developer/sdk/v1/upload/multipart/initiate": { post: { operationId: "initiateSdkUpload", @@ -1673,6 +1902,167 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ uploadId: { type: "string" }, }, }, + WebhookEndpoint: { + type: "object", + required: [ + "id", + "url", + "events", + "enabled", + "disabledReason", + "secretPrefix", + "lastDeliveryAt", + "lastDeliveryStatus", + "lastResponseStatus", + "lastErrorCode", + "consecutiveFailures", + "createdAt", + "updatedAt", + ], + properties: { + id: { type: "string" }, + url: { type: "string", format: "uri" }, + events: webhookEventsProperty, + enabled: { + type: "boolean", + description: "Events are only sent to an enabled endpoint.", + }, + disabledReason: { + type: ["string", "null"], + enum: ["delivery_failures", null], + description: `delivery_failures when Cap disabled the endpoint after at least ${WEBHOOK_DISABLE_AFTER_FAILURES} failed attempts in a row over five days or more. null otherwise.`, + }, + secretPrefix: { + type: "string", + description: + "The first characters of the signing secret, to tell endpoints apart. The secret itself is only returned when the endpoint is created.", + }, + lastDeliveryAt: { + type: ["string", "null"], + format: "date-time", + description: "When Cap last called the endpoint.", + }, + lastDeliveryStatus: { + type: ["string", "null"], + enum: ["succeeded", "failed", null], + }, + lastResponseStatus: { + type: ["integer", "null"], + description: + "The HTTP status the endpoint last answered with, or null when it did not answer.", + }, + lastErrorCode: { + type: ["string", "null"], + enum: [ + "http_error", + "redirect", + "timeout", + "connection_failed", + "private_address", + "invalid_url", + "https_required", + "credentials_in_url", + "unsupported_port", + null, + ], + description: + "Why the last delivery failed: http_error for a status outside 2xx, redirect for a 3xx (redirects are not followed), timeout for no answer in 10 seconds, connection_failed when the connection or TLS handshake failed, private_address when the host resolved to a private or reserved address. null after a success.", + }, + consecutiveFailures: { + type: "integer", + description: "Failed attempts since the last success.", + }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { + type: "string", + format: "date-time", + description: + "When the endpoint's settings last changed. Deliveries do not move it.", + }, + }, + }, + WebhookEndpointWithSecret: { + allOf: [ + ref("WebhookEndpoint"), + { + type: "object", + required: ["secret"], + properties: { + secret: { + type: "string", + description: + "The signing secret, starting with cws_. Returned once, when the endpoint is created.", + }, + }, + }, + ], + }, + WebhookEndpointList: { + type: "object", + required: ["data", "pagination"], + properties: { + data: { type: "array", items: ref("WebhookEndpoint") }, + pagination: { + type: "object", + required: ["limit", "offset", "hasMore"], + properties: { + limit: { type: "integer" }, + offset: { type: "integer" }, + hasMore: { type: "boolean" }, + }, + }, + }, + }, + CreateWebhookEndpointRequest: { + type: "object", + additionalProperties: false, + required: ["url"], + properties: { + url: { type: "string", format: "uri", maxLength: 2048 }, + events: { + ...webhookEventsProperty, + description: + "The event types to send. Leave it out to receive every type.", + }, + enabled: { type: "boolean", default: true }, + }, + }, + UpdateWebhookEndpointRequest: { + type: "object", + additionalProperties: false, + minProperties: 1, + properties: { + url: { type: "string", format: "uri", maxLength: 2048 }, + events: webhookEventsProperty, + enabled: { type: "boolean" }, + }, + }, + WebhookTestResult: { + type: "object", + required: [ + "eventId", + "type", + "delivered", + "responseStatus", + "errorCode", + "durationMs", + ], + properties: { + eventId: { type: "string" }, + type: { type: "string", const: WEBHOOK_TEST_EVENT_TYPE }, + delivered: { + type: "boolean", + description: "true when the endpoint answered with a 2xx status.", + }, + responseStatus: { type: ["integer", "null"] }, + errorCode: { + type: ["string", "null"], + description: + "Why the delivery failed, with the same values as lastErrorCode. null when delivered.", + }, + durationMs: { type: "integer" }, + }, + }, }, responses: { BadRequest: json( @@ -1736,6 +2126,14 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ ref("Error"), "Unexpected server error (code internal_error). Retry with backoff.", ), + WebhookNotFound: json( + ref("Error"), + "No webhook endpoint with this ID belongs to the app (code webhook_not_found).", + ), + WebhookLimitReached: json( + ref("Error"), + `The app already has ${MAX_WEBHOOK_ENDPOINTS_PER_APP} webhook endpoints (code webhook_limit_reached). Delete one first.`, + ), }, headers: { RateLimitLimit: { From e7b01839d0e245b5b4ea95206fef2ee1d1b6336e Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:13:02 -0500 Subject: [PATCH 05/13] docs(web): describe the webhook test event on its own in the OpenAPI description --- .../unit/developer-webhook-docs.test.ts | 17 +++++++++++++++++ apps/web/lib/developer-openapi.ts | 16 ++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts index 5a0c463b9d1..827302a4ff4 100644 --- a/apps/web/__tests__/unit/developer-webhook-docs.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -197,6 +197,23 @@ describe("webhooks in the OpenAPI description", () => { } }); + it("promises retries for video events only", () => { + const outbound = spec.webhooks as Record< + string, + { post: { description: string } } + >; + for (const type of WEBHOOK_EVENT_TYPES) { + expect(outbound[type]?.post.description).toContain( + `up to ${WEBHOOK_MAX_ATTEMPTS} attempts`, + ); + expect(outbound[type]?.post.description).toContain("enabled endpoint"); + } + const test = outbound[WEBHOOK_TEST_EVENT_TYPE]?.post.description ?? ""; + expect(test).toContain("never retried"); + expect(test).toContain("whether or not that endpoint is enabled"); + expect(test).not.toContain("attempts"); + }); + it("describes the request Cap sends for each event", () => { const outbound = spec.webhooks as Record; expect(Object.keys(outbound).sort()).toEqual( diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 497c0f2bdb4..e8ae83f0406 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -231,14 +231,25 @@ const webhookEventsProperty = { description: "The event types sent to this endpoint.", }; -const webhookDelivery = (type: string, summary: string, data: object) => ({ +const WEBHOOK_VERIFY_NOTE = + "Verify Cap-Signature before trusting the body, and use the event id to ignore a delivery you have already handled."; + +const videoEventDescription = (type: string) => + `Cap sends this request to every enabled endpoint subscribed to ${type}. Answer with any 2xx status within 10 seconds. Any other answer, a redirect or no answer is retried, up to ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day. ${WEBHOOK_VERIFY_NOTE}`; + +const webhookDelivery = ( + type: string, + summary: string, + data: object, + description: string = videoEventDescription(type), +) => ({ post: { operationId: `receive${type .split(".") .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`) .join("")}`, summary, - description: `Cap sends this request to every enabled endpoint subscribed to ${type}. Answer with any 2xx status within 10 seconds. Any other answer, a redirect or no answer is retried, up to ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day. Verify Cap-Signature before trusting the body, and use the event id to ignore a delivery you have already handled.`, + description, tags: ["Webhooks"], security: [], parameters: [ @@ -541,6 +552,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ required: ["webhookId"], properties: { webhookId: { type: "string" } }, }, + `Cap sends this request once, to the one endpoint named in sendWebhookTestEvent, whether or not that endpoint is enabled. It is never retried: what the endpoint answers within 10 seconds is returned by sendWebhookTestEvent. ${WEBHOOK_VERIFY_NOTE}`, ), }, paths: { From 6f3111a48fbf86238f7ce73b8018f6ef380d69e6 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:13:02 -0500 Subject: [PATCH 06/13] fix(web): tighten webhook delivery after review Record a delivery's count, streak start and disable decision under the endpoint's row lock. Run the host lookup inside the delivery deadline and sign at the moment of sending. Measure a URL as it is stored, after encoding. Close the connection once the status is known, and drop a connection that sends content after a no-content answer. --- .../unit/developer-webhook-delivery.test.ts | 136 +++++++++++++++++ .../unit/developer-webhook-guard.test.ts | 11 ++ .../unit/developer-webhooks-api.test.ts | 13 ++ apps/web/lib/developer-webhook-delivery.ts | 138 ++++++++++-------- apps/web/lib/storage-fetch.ts | 8 +- 5 files changed, 241 insertions(+), 65 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-delivery.test.ts b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts index a14f9c68340..a5d00f767da 100644 --- a/apps/web/__tests__/unit/developer-webhook-delivery.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts @@ -129,6 +129,7 @@ describe("a customer endpoint over real HTTP", () => { method?: string; }[] = []; const held: IncomingMessage[] = []; + const closed: string[] = []; beforeAll(async () => { server = createServer((req, res) => { @@ -150,6 +151,21 @@ describe("a customer endpoint over real HTTP", () => { held.push(req); return; } + if (req.url === "/reset-content-flood") { + res.writeHead(205, { "transfer-encoding": "chunked" }); + const flood = setInterval(() => res.write("x".repeat(1024)), 5); + req.socket.once("close", () => { + clearInterval(flood); + closed.push(req.url ?? ""); + }); + return; + } + if (req.url === "/reset-content-stall") { + res.writeHead(205, { "transfer-encoding": "chunked" }); + res.flushHeaders(); + req.socket.once("close", () => closed.push(req.url ?? "")); + return; + } if (req.url === "/moved") { res.writeHead(307, { location: "/ok" }); res.end(); @@ -297,6 +313,19 @@ describe("a customer endpoint over real HTTP", () => { expect(Date.now() - started).toBeLessThan(2_000); }); + it.each(["/reset-content-flood", "/reset-content-stall"])( + "closes the connection after a no-content answer from %s", + async (path) => { + expect(await sendPinned(path, 5_000)).toMatchObject({ + ok: true, + responseStatus: 205, + }); + await vi.waitFor(() => expect(closed).toContain(path), { + timeout: 2_000, + }); + }, + ); + it("does not wait for or read a response body that never ends", async () => { const started = Date.now(); expect(await sendPinned("/big", 5_000)).toMatchObject({ @@ -319,6 +348,57 @@ describe("a customer endpoint over real HTTP", () => { }); }); +describe("the delivery deadline and the signature time", () => { + const event = { + url: "https://hooks.example.com/cap", + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }; + + it("counts a host lookup that never finishes against the deadline", async () => { + const request = vi.fn(); + const started = Date.now(); + const result = await sendDeveloperWebhook(event, { + allowPrivate: false, + isBlocked: () => new Promise(() => {}), + request, + timeoutMs: 100, + }); + expect(result).toMatchObject({ ok: false, errorCode: "timeout" }); + expect(Date.now() - started).toBeLessThan(1_500); + expect(request).not.toHaveBeenCalled(); + }); + + it("signs with the time the request is sent, not the time the lookup began", async () => { + let clock = 1_794_500_000_000; + const seen: RequestInit[] = []; + await sendDeveloperWebhook(event, { + allowPrivate: false, + now: () => clock, + isBlocked: async () => { + clock += 7_000; + return false; + }, + request: async (_url, init) => { + seen.push(init); + return new Response(null, { status: 204 }); + }, + }); + const header = new Headers(seen[0]?.headers).get("cap-signature"); + expect(header).toMatch(/^t=1794500007,/); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: job.body, + header, + now: clock, + }), + ).toBe(true); + }); +}); + describe("one delivery attempt", () => { it("delivers, records the result and clears the failure streak", async () => { seedEndpoint({ @@ -440,6 +520,62 @@ describe("disabling an endpoint that keeps failing", () => { expect(endpoint()).toMatchObject({ enabled: true, consecutiveFailures: 4 }); }); + it("keeps the count and the streak start together when a success and a failure land at once", async () => { + const succeeded = { ok: true as const, responseStatus: 200, durationMs: 5 }; + for (let round = 0; round < 20; round++) { + fake().reset(); + seedEndpoint({ consecutiveFailures: 3, failingSince: longAgo }); + const pair = [ + recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }), + recordWebhookDelivery("wh-1", succeeded, { + countsTowardStreak: true, + now, + }), + ]; + await Promise.all(round % 2 === 0 ? pair : pair.reverse()); + const row = endpoint(); + expect(row.consecutiveFailures === 0).toBe(row.failingSince === null); + if (row.failingSince !== null) { + expect([now, longAgo]).toContain(row.failingSince); + expect(row.consecutiveFailures).toBeGreaterThan(0); + } + expect(row.enabled).toBe(true); + } + }); + + it("starts a new streak from the failure that began it", async () => { + seedEndpoint(); + const first = new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS - 60_000); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now: first, + }); + await recordWebhookDelivery( + "wh-1", + { ok: true, responseStatus: 200, durationMs: 5 }, + { countsTowardStreak: true, now: first }, + ); + for ( + let attempt = 0; + attempt < WEBHOOK_DISABLE_AFTER_FAILURES + 5; + attempt++ + ) { + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + } + expect(endpoint()).toMatchObject({ + enabled: true, + disabledReason: null, + failingSince: now, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + }); + }); + it("never counts a test event towards the streak", async () => { seedEndpoint({ consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, diff --git a/apps/web/__tests__/unit/developer-webhook-guard.test.ts b/apps/web/__tests__/unit/developer-webhook-guard.test.ts index 50251156ad4..d132426670a 100644 --- a/apps/web/__tests__/unit/developer-webhook-guard.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-guard.test.ts @@ -98,6 +98,17 @@ describe("webhook URL policy in a production build", () => { expect(webhookUrlProblem(url)).toBe(problem); }); + it("measures the URL as it is stored, after encoding", () => { + const accented = `https://hooks.example.com/${"é".repeat(400)}`; + expect(accented.length).toBeLessThan(2_048); + expect(new URL(accented).href.length).toBeGreaterThan(2_048); + expect(webhookUrlProblem(accented)).toBe("invalid_url"); + expect(webhookUrlProblem(accented, true)).toBe("invalid_url"); + expect( + webhookUrlProblem(`https://hooks.example.com/${"é".repeat(300)}`), + ).toBeNull(); + }); + it("refuses a name that resolves to a private address", async () => { for (const address of ["10.0.0.5", "127.0.0.1", "::1", "::ffff:10.0.0.5"]) { expect( diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts index b5773286c37..3498b9194f9 100644 --- a/apps/web/__tests__/unit/developer-webhooks-api.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -189,6 +189,19 @@ describe("creating a webhook endpoint", () => { expect(rows()).toHaveLength(0); }); + it("refuses a URL that is too long once encoded, on create and on update", async () => { + const accented = `https://hooks.example.com/${"é".repeat(400)}`; + const created = await create(A, { url: accented }); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + + const { id } = (await create(A)).body.data; + const updated = await send("PATCH", `/${id}`, A, { url: accented }); + expect(updated.status).toBe(400); + expect(rows()[0]?.url).toBe("https://hooks.example.com/cap"); + }); + it("refuses a name that resolves to a private address", async () => { context.resolved = ["192.168.1.20"]; const created = await create(); diff --git a/apps/web/lib/developer-webhook-delivery.ts b/apps/web/lib/developer-webhook-delivery.ts index 033a3e31b21..f001287d02b 100644 --- a/apps/web/lib/developer-webhook-delivery.ts +++ b/apps/web/lib/developer-webhook-delivery.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { decrypt } from "@cap/database/crypto"; import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; -import { and, eq, gte, isNull, lte, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { WEBHOOK_DISABLE_AFTER_FAILURES, WEBHOOK_DISABLE_AFTER_MS, @@ -65,6 +65,10 @@ const URL_PROBLEM_MESSAGES: Record = { private_address: "The URL must point to a public address", }; +// The stored URL is the parsed one, which is longer than what was sent when +// it holds characters that need encoding. +export const WEBHOOK_URL_MAX_LENGTH = 2_048; + export const webhookUrlProblemMessage = (problem: WebhookUrlProblem) => URL_PROBLEM_MESSAGES[problem]; @@ -77,8 +81,10 @@ export function webhookUrlProblem( if (problem === "invalid_url" || problem === "credentials_in_url") { return problem; } + const url = new URL(value); + if (url.href.length > WEBHOOK_URL_MAX_LENGTH) return "invalid_url"; if (allowPrivate) return null; - if (new URL(value).protocol !== "https:") return "https_required"; + if (url.protocol !== "https:") return "https_required"; return problem; } @@ -123,12 +129,6 @@ export async function sendDeveloperWebhook( }); const allowPrivate = guards.allowPrivate ?? allowsPrivateWebhookTargets(); - const problem = await checkWebhookUrl(input.url, { - allowPrivate, - isBlocked: guards.isBlocked, - }); - if (problem) return failed(problem); - const request = guards.request ?? (allowPrivate ? requestAnyAddress : requestPublicOnly); const abort = new AbortController(); @@ -137,8 +137,26 @@ export async function sendDeveloperWebhook( timedOut = true; abort.abort(); }, guards.timeoutMs ?? WEBHOOK_TIMEOUT_MS); + const deadline = new Promise((_, reject) => { + abort.signal.addEventListener( + "abort", + () => reject(new Error("Webhook delivery timed out")), + { once: true }, + ); + }); + deadline.catch(() => {}); try { + // Resolving the host is part of the delivery, so it runs inside the + // same deadline as the request. + const check = checkWebhookUrl(input.url, { + allowPrivate, + isBlocked: guards.isBlocked, + }); + check.catch(() => {}); + const problem = await Promise.race([check, deadline]); + if (problem) return failed(problem); + const response = await request(input.url, { method: "POST", headers: { @@ -147,7 +165,7 @@ export async function sendDeveloperWebhook( [WEBHOOK_SIGNATURE_HEADER]: signWebhookBody( input.secret, input.body, - Math.floor(started / 1000), + Math.floor(now() / 1000), ), [WEBHOOK_EVENT_ID_HEADER]: input.eventId, [WEBHOOK_EVENT_TYPE_HEADER]: input.eventType, @@ -171,71 +189,63 @@ export async function sendDeveloperWebhook( ); } finally { clearTimeout(timer); + // Only the status is wanted. Closing the connection here means an + // endpoint that keeps sending, or never finishes, holds nothing open. + abort.abort(); } } -const keepUpdatedAt = { - updatedAt: sql`${developerWebhookEndpoints.updatedAt}`, -}; - +// The count, the start of the streak and the decision to disable are read and +// written under the endpoint's row lock, so a success and a failure recorded +// at the same moment cannot leave a streak start behind without its count. export async function recordWebhookDelivery( endpointId: string, result: WebhookSendResult, options: { countsTowardStreak: boolean; now?: Date }, ): Promise { const now = options.now ?? new Date(); - const last = { - lastDeliveryAt: now, - lastDeliveryStatus: result.ok - ? ("succeeded" as const) - : ("failed" as const), - lastResponseStatus: result.responseStatus, - lastErrorCode: result.ok ? null : result.errorCode, - ...keepUpdatedAt, - }; - const streak = !options.countsTowardStreak - ? {} - : result.ok - ? { consecutiveFailures: 0, failingSince: null } - : { - consecutiveFailures: sql`${developerWebhookEndpoints.consecutiveFailures} + ${1}`, - }; - await db() - .update(developerWebhookEndpoints) - .set({ ...last, ...streak }) - .where(eq(developerWebhookEndpoints.id, endpointId)); + await db().transaction(async (tx) => { + const [endpoint] = await tx + .select({ + enabled: developerWebhookEndpoints.enabled, + consecutiveFailures: developerWebhookEndpoints.consecutiveFailures, + failingSince: developerWebhookEndpoints.failingSince, + }) + .from(developerWebhookEndpoints) + .where(eq(developerWebhookEndpoints.id, endpointId)) + .for("update"); + if (!endpoint) return; - if (result.ok || !options.countsTowardStreak) return; - await db() - .update(developerWebhookEndpoints) - .set({ failingSince: now, ...keepUpdatedAt }) - .where( - and( - eq(developerWebhookEndpoints.id, endpointId), - isNull(developerWebhookEndpoints.failingSince), - ), - ); - await db() - .update(developerWebhookEndpoints) - .set({ - enabled: false, - disabledReason: WEBHOOK_DISABLED_BY_FAILURES, - ...keepUpdatedAt, - }) - .where( - and( - eq(developerWebhookEndpoints.id, endpointId), - eq(developerWebhookEndpoints.enabled, true), - gte( - developerWebhookEndpoints.consecutiveFailures, - WEBHOOK_DISABLE_AFTER_FAILURES, - ), - lte( - developerWebhookEndpoints.failingSince, - new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS), - ), - ), - ); + const failures = endpoint.consecutiveFailures + 1; + const failingSince = endpoint.failingSince ?? now; + const disable = + endpoint.enabled && + failures >= WEBHOOK_DISABLE_AFTER_FAILURES && + now.getTime() - failingSince.getTime() >= WEBHOOK_DISABLE_AFTER_MS; + const streak = !options.countsTowardStreak + ? {} + : result.ok + ? { consecutiveFailures: 0, failingSince: null } + : { + consecutiveFailures: failures, + failingSince, + ...(disable + ? { enabled: false, disabledReason: WEBHOOK_DISABLED_BY_FAILURES } + : {}), + }; + + await tx + .update(developerWebhookEndpoints) + .set({ + lastDeliveryAt: now, + lastDeliveryStatus: result.ok ? "succeeded" : "failed", + lastResponseStatus: result.responseStatus, + lastErrorCode: result.ok ? null : result.errorCode, + updatedAt: sql`${developerWebhookEndpoints.updatedAt}`, + ...streak, + }) + .where(eq(developerWebhookEndpoints.id, endpointId)); + }); } async function findDeliverableEndpoint(endpointId: string) { diff --git a/apps/web/lib/storage-fetch.ts b/apps/web/lib/storage-fetch.ts index 5668496c35f..aaed7db873c 100644 --- a/apps/web/lib/storage-fetch.ts +++ b/apps/web/lib/storage-fetch.ts @@ -87,7 +87,13 @@ export function requestPublicOnly( else if (value !== undefined) headers.set(name, value); } const hasBody = method !== "HEAD" && !BODYLESS_STATUSES.has(status); - if (!hasBody) response.resume(); + if (!hasBody) { + // These answers carry no content. A server that sends some + // anyway loses the connection instead of having it read + // without end. + response.on("error", () => {}); + response.on("data", () => response.destroy()); + } resolve( new Response( hasBody From e4e9d331de911c81694a5296ac0740bfc62c82bf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:38:21 -0500 Subject: [PATCH 07/13] chore(database): move the webhook endpoints migration to 0059 Main took 0056 and 0057 and 0058 is reserved for the storage PR. The SQL is unchanged and the snapshot now chains from main's 0057. --- ...k_endpoints.sql => 0059_developer_webhook_endpoints.sql} | 0 .../meta/{0058_snapshot.json => 0059_snapshot.json} | 0 packages/database/migrations/meta/_journal.json | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/database/migrations/{0058_developer_webhook_endpoints.sql => 0059_developer_webhook_endpoints.sql} (100%) rename packages/database/migrations/meta/{0058_snapshot.json => 0059_snapshot.json} (100%) diff --git a/packages/database/migrations/0058_developer_webhook_endpoints.sql b/packages/database/migrations/0059_developer_webhook_endpoints.sql similarity index 100% rename from packages/database/migrations/0058_developer_webhook_endpoints.sql rename to packages/database/migrations/0059_developer_webhook_endpoints.sql diff --git a/packages/database/migrations/meta/0058_snapshot.json b/packages/database/migrations/meta/0059_snapshot.json similarity index 100% rename from packages/database/migrations/meta/0058_snapshot.json rename to packages/database/migrations/meta/0059_snapshot.json diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 4963a02e00e..deeda5de4be 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -409,10 +409,10 @@ "breakpoints": true }, { - "idx": 58, + "idx": 59, "version": "5", - "when": 1794547810442, - "tag": "0058_developer_webhook_endpoints", + "when": 1794548265583, + "tag": "0059_developer_webhook_endpoints", "breakpoints": true } ] From 70e83b2101580133bb77f3314ee89cc3b9ef1aa3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:38:21 -0500 Subject: [PATCH 08/13] feat(web): rotate a webhook signing secret POST /webhooks/:id/rotate-secret replaces the secret and returns the new one once. Every later delivery, retries included, is signed with it. --- .../unit/developer-webhooks-api.test.ts | 66 +++++++++++++++++++ .../unit/developer-webhooks-auth.test.ts | 3 + .../api/developer/v1/[...route]/webhooks.ts | 15 +++++ apps/web/content/docs/api/rest-api.mdx | 1 + apps/web/content/docs/api/webhooks.mdx | 12 +++- apps/web/lib/developer-openapi.ts | 21 +++++- apps/web/lib/developer-webhooks.ts | 25 +++++++ 7 files changed, 141 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts index 3498b9194f9..c0234edcba0 100644 --- a/apps/web/__tests__/unit/developer-webhooks-api.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -323,6 +323,7 @@ describe("one app cannot reach another app's endpoints", () => { await send("GET", `/${id}`, B), await send("PATCH", `/${id}`, B, { enabled: false }), await send("PATCH", `/${id}`, B, { url: "https://evil.example.com/x" }), + await send("POST", `/${id}/rotate-secret`, B), await send("DELETE", `/${id}`, B), await send("POST", `/${id}/test`, B), ]; @@ -352,6 +353,7 @@ describe("one app cannot reach another app's endpoints", () => { ["GET", `/${id}`], ["PATCH", `/${id}`], ["DELETE", `/${id}`], + ["POST", `/${id}/rotate-secret`], ["POST", `/${id}/test`], ["POST", "/"], ] as const) { @@ -360,6 +362,70 @@ describe("one app cannot reach another app's endpoints", () => { }); }); +describe("rotating the signing secret", () => { + it("returns a new secret once, stores it encrypted and signs with it from then on", async () => { + const { id, secret: oldSecret } = (await create(A)).body.data; + const response = await send("POST", `/${id}/rotate-secret`, A); + expect(response.status).toBe(200); + const { data } = await response.json(); + expect(data.secret).toMatch(/^cws_[0-9a-z]{30}$/); + expect(data.secret).not.toBe(oldSecret); + expect(data).toMatchObject({ id, secretPrefix: data.secret.slice(0, 12) }); + + const [row] = rows(); + expect(await decrypt(String(row?.encryptedSecret))).toBe(data.secret); + for (const later of [ + await send("GET", `/${id}`, A), + await send("GET", "/", A), + ]) { + const text = await later.text(); + expect(text).not.toContain(data.secret); + expect(text).not.toContain(oldSecret); + } + + const seen: RequestInit[] = []; + context.request = async (_url, init) => { + seen.push(init); + return new Response(null, { status: 204 }); + }; + await send("POST", `/${id}/test`, A); + const header = new Headers(seen[0]?.headers).get("cap-signature"); + const body = String(seen[0]?.body); + expect(verifyWebhookSignature({ secret: data.secret, body, header })).toBe( + true, + ); + expect(verifyWebhookSignature({ secret: oldSecret, body, header })).toBe( + false, + ); + }); + + it("keeps the endpoint's settings and delivery state", async () => { + const { id } = (await create(A)).body.data; + await send("PATCH", `/${id}`, A, { + url: "https://hooks.example.com/other", + events: ["video.errored"], + enabled: false, + }); + const before = rows()[0]; + await send("POST", `/${id}/rotate-secret`, A); + expect(rows()[0]).toMatchObject({ + url: before?.url, + events: before?.events, + enabled: false, + consecutiveFailures: before?.consecutiveFailures, + }); + }); + + it("answers not found for a deleted endpoint", async () => { + const { id } = (await create(A)).body.data; + await send("DELETE", `/${id}`, A); + const encrypted = rows()[0]?.encryptedSecret; + const response = await send("POST", `/${id}/rotate-secret`, A); + expect(response.status).toBe(404); + expect(rows()[0]?.encryptedSecret).toBe(encrypted); + }); +}); + describe("sending a test event", () => { it("sends one signed webhook.test event and reports what came back", async () => { const { id, secret } = (await create(A)).body.data; diff --git a/apps/web/__tests__/unit/developer-webhooks-auth.test.ts b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts index f8bbfc9b86c..db941363651 100644 --- a/apps/web/__tests__/unit/developer-webhooks-auth.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ deleteWebhookEndpoint: vi.fn(), getWebhookEndpoint: vi.fn(), listWebhookEndpoints: vi.fn(), + rotateWebhookEndpointSecret: vi.fn(), sendWebhookTestEvent: vi.fn(), updateWebhookEndpoint: vi.fn(), }, @@ -61,6 +62,7 @@ const ROUTES = [ ["GET", "/wh-1"], ["PATCH", "/wh-1"], ["DELETE", "/wh-1"], + ["POST", "/wh-1/rotate-secret"], ["POST", "/wh-1/test"], ] as const; @@ -133,6 +135,7 @@ describe("webhook routes and a bad key", () => { mocks.store.createWebhookEndpoint.mockResolvedValue(missing); mocks.store.updateWebhookEndpoint.mockResolvedValue(missing); mocks.store.deleteWebhookEndpoint.mockResolvedValue(missing); + mocks.store.rotateWebhookEndpointSecret.mockResolvedValue(missing); mocks.store.sendWebhookTestEvent.mockResolvedValue(missing); for (const [method, path] of ROUTES) { diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts index 58f1e79f4da..ce6d78bae4b 100644 --- a/apps/web/app/api/developer/v1/[...route]/webhooks.ts +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -8,6 +8,7 @@ import { deleteWebhookEndpoint, getWebhookEndpoint, listWebhookEndpoints, + rotateWebhookEndpointSecret, sendWebhookTestEvent, serializeWebhookEndpoint, serializeWebhookTestResult, @@ -131,6 +132,20 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); +app.post("/:id/rotate-secret", async (c) => { + const result = await rotateWebhookEndpointSecret( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: { + ...serializeWebhookEndpoint(result.endpoint), + secret: result.secret, + }, + }); +}); + app.post("/:id/test", async (c) => { const result = await sendWebhookTestEvent( c.get("developerAppId"), diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 8fb84a74076..f3bdba0dd42 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1084,6 +1084,7 @@ Have Cap call your server when a video is ready or has failed, instead of pollin | `GET /api/developer/v1/webhooks/:id` | Read one endpoint and how Cap's last call to it went | | `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled` | | `DELETE /api/developer/v1/webhooks/:id` | Delete an endpoint | +| `POST /api/developer/v1/webhooks/:id/rotate-secret` | Replace the signing secret. The new one is returned once | | `POST /api/developer/v1/webhooks/:id/test` | Send a test event and report what the endpoint answered | **Example request:** diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 868baf1c50f..49f0e68a863 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -66,7 +66,7 @@ curl -X POST https://cap.so/api/developer/v1/webhooks \ } ``` -`secret` is the endpoint's signing secret. It is returned once, in this response, and never again. Store it with your other secrets. If you lose it, delete the endpoint and create a new one. +`secret` is the endpoint's signing secret. It is returned once, in this response, and never again. Store it with your other secrets. If you lose it or it leaks, [rotate it](#rotate-the-signing-secret). The URL must: @@ -85,6 +85,7 @@ Anything else returns `400` with code `invalid_webhook_url`. An app can have 5 e | `GET /api/developer/v1/webhooks/:id` | Read one endpoint and the result of its last delivery | | `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled`. Send at least one | | `DELETE /api/developer/v1/webhooks/:id` | Delete the endpoint. Deliveries waiting to be retried are dropped | +| `POST /api/developer/v1/webhooks/:id/rotate-secret` | [Rotate the signing secret](#rotate-the-signing-secret) | | `POST /api/developer/v1/webhooks/:id/test` | [Send a test event](#send-a-test-event) | An ID that does not belong to the app returns `404` with code `webhook_not_found`. @@ -107,6 +108,15 @@ Every endpoint is returned with these fields: | `createdAt` | string | Creation timestamp (ISO 8601) | | `updatedAt` | string | When the endpoint's settings last changed | +## Rotate the signing secret + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd/rotate-secret \ + -H "Authorization: Bearer csk_your_secret_key" +``` + +The response is the endpoint with a new `secret`, returned once, like the one from create. The old secret stops working at once: every request from then on, including a retry of an earlier event, is signed with the new one. To rotate without dropping an event, have your receiver accept a signature from either secret, rotate, store the new secret, then remove the old one. + ## The request Cap sends ``` diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 3475d74d261..fd11d60402b 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -978,7 +978,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ patch: { operationId: "updateWebhookEndpoint", summary: "Update a webhook endpoint", - description: `Changes an endpoint's URL, its event types or whether it is enabled. Send at least one of them. Setting enabled to true turns an endpoint back on after Cap disabled it and clears its failure count. The signing secret does not change. ${WEBHOOK_URL_RULES}`, + description: `Changes an endpoint's URL, its event types or whether it is enabled. Send at least one of them. Setting enabled to true turns an endpoint back on after Cap disabled it and clears its failure count. The signing secret does not change; use rotateWebhookSecret for that. ${WEBHOOK_URL_RULES}`, tags: ["Webhooks"], security: secretAuth, parameters: [webhookIdParam], @@ -1009,6 +1009,25 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ }, }, }, + "/api/developer/v1/webhooks/{id}/rotate-secret": { + post: { + operationId: "rotateWebhookSecret", + summary: "Rotate a webhook signing secret", + description: + "Replaces the endpoint's signing secret and returns the new one. Store it, because it is never returned again. The old secret stops working at once: every delivery from this moment, including a retry of an earlier event, is signed with the new secret. Deploy a receiver that accepts both secrets first, rotate, then remove the old one.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpointWithSecret")), + "The endpoint and its new signing secret.", + ), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, "/api/developer/v1/webhooks/{id}/test": { post: { operationId: "sendWebhookTestEvent", diff --git a/apps/web/lib/developer-webhooks.ts b/apps/web/lib/developer-webhooks.ts index 3fb6c180e93..77b8b6010e3 100644 --- a/apps/web/lib/developer-webhooks.ts +++ b/apps/web/lib/developer-webhooks.ts @@ -220,6 +220,31 @@ export async function updateWebhookEndpoint( return endpoint ? { ok: true, endpoint } : notFound(); } +// The old secret stops working at once: every delivery, including a retry of +// an earlier event, is signed when it is sent. The new secret is returned +// here and never again. +export async function rotateWebhookEndpointSecret( + appId: string, + endpointId: string, +): Promise< + { ok: true; endpoint: WebhookEndpointRow; secret: string } | WebhookFailure +> { + const current = await getWebhookEndpoint(appId, endpointId); + if (!current) return notFound(); + + const secret = createWebhookSecret(); + await db() + .update(developerWebhookEndpoints) + .set({ + secretPrefix: secret.slice(0, SECRET_PREFIX_LENGTH), + encryptedSecret: await encrypt(secret), + }) + .where(liveEndpoint(appId, endpointId)); + + const endpoint = await getWebhookEndpoint(appId, endpointId); + return endpoint ? { ok: true, endpoint, secret } : notFound(); +} + export async function deleteWebhookEndpoint( appId: string, endpointId: string, From dbe3a0de061f13f8879f5a58b7338c6efc8c945f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:38:21 -0500 Subject: [PATCH 09/13] test(web): DNS rebinding between check and connect, and the shared guard for storage callers --- .../unit/developer-webhook-rebinding.test.ts | 173 ++++++++++++++++++ .../__tests__/unit/s3-endpoint-policy.test.ts | 35 ++++ apps/web/__tests__/unit/storage-fetch.test.ts | 23 +++ 3 files changed, 231 insertions(+) create mode 100644 apps/web/__tests__/unit/developer-webhook-rebinding.test.ts diff --git a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts new file mode 100644 index 00000000000..4fa8ca8d681 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts @@ -0,0 +1,173 @@ +import type { LookupAddress } from "node:dns"; +import { createServer, request as httpRequest, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// The name below answers with a public address the first time it is asked +// (the check) and a private one after that (the connection). The guard must +// dial only an address it has checked. +const dns = vi.hoisted(() => ({ + checkAnswers: [] as string[], + connectAnswers: [] as string[], + checkCalls: 0, + connectCalls: 0, +})); + +vi.mock("node:dns/promises", async (importOriginal) => { + const actual = await importOriginal(); + const lookup = async (hostname: string) => { + if (hostname !== "rebind.example") return actual.lookup(hostname); + dns.checkCalls += 1; + return dns.checkAnswers.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + }; + return { ...actual, default: { ...actual, lookup }, lookup }; +}); + +vi.mock("node:dns", async (importOriginal) => { + const actual = await importOriginal(); + const lookup = ( + hostname: string, + options: { all?: boolean } | number | undefined, + callback: ( + error: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family?: number, + ) => void, + ) => { + if (hostname !== "rebind.example") { + return (actual.lookup as (...args: unknown[]) => void)( + hostname, + options, + callback, + ); + } + dns.connectCalls += 1; + const answers = dns.connectAnswers.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + const all = typeof options === "object" && options?.all; + queueMicrotask(() => { + if (all) callback(null, answers); + else callback(null, answers[0]?.address ?? "", answers[0]?.family); + }); + }; + return { ...actual, default: { ...actual, lookup }, lookup }; +}); + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { sendDeveloperWebhook } = await import( + "@/lib/developer-webhook-delivery" +); +const { publicOnlyLookup } = await import("@/lib/s3-endpoint-policy"); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); +const nodeDns = await import("node:dns"); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready" }), +}; + +let server: Server; +let port: number; +let hits = 0; + +beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("internal"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(() => { + hits = 0; + dns.checkCalls = 0; + dns.connectCalls = 0; + dns.checkAnswers = ["93.184.216.34"]; + dns.connectAnswers = ["127.0.0.1"]; +}); + +describe("a name that changes to a private address between check and connect", () => { + it("passes the check, then is refused at connect and nothing is sent", async () => { + const result = await sendDeveloperWebhook({ + ...event, + url: "https://rebind.example/cap", + }); + expect(dns.checkCalls).toBe(1); + expect(dns.connectCalls).toBe(1); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(hits).toBe(0); + }); + + it.each([ + ["the metadata address", "169.254.169.254"], + ["a unique local v6 address", "fd00:ec2::254"], + ["a mapped v4 loopback", "::ffff:127.0.0.1"], + ["one private address among public ones", "93.184.216.34,10.0.0.5"], + ])("refuses %s at connect", async (_label, answers) => { + dns.connectAnswers = answers.split(","); + const result = await sendDeveloperWebhook({ + ...event, + url: "https://rebind.example/cap", + }); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + }); + + it("dials only the address the connection's own lookup checked", async () => { + // Without the guard, the rebound answer is what gets dialled: this + // plain request with the same name reaches the local server. + const unguarded = await new Promise((resolve, reject) => { + const req = httpRequest( + { + host: "rebind.example", + port, + path: "/", + lookup: nodeDns.lookup, + }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(unguarded).toBe(200); + expect(hits).toBe(1); + + await expect( + requestPublicOnly(`http://rebind.example:${port}/`, { + method: "POST", + body: "{}", + }), + ).rejects.toThrow("Refused to connect to private address 127.0.0.1"); + expect(hits).toBe(1); + }); + + it("checks every address the lookup returns, not only the first", async () => { + dns.connectAnswers = ["93.184.216.34", "127.0.0.1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); +}); diff --git a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts index 92b46116268..8a87c98b8fa 100644 --- a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts +++ b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts @@ -61,6 +61,41 @@ describe("isPrivateAddress", () => { ])("allows %s", (address) => { expect(isPrivateAddress(address)).toBe(false); }); + + // The webhook ranges (documentation, 6to4, Teredo, discard, local NAT64) + // must not catch the public addresses storage providers answer with. + it.each([ + "3.5.140.2", + "52.92.16.1", + "104.16.0.1", + "162.159.140.1", + "172.67.1.1", + "34.120.0.1", + "2001:1::1", + "2001:db9::1", + "2003::1", + "2600:9000::1", + "2606:4700::1111", + "2a06:98c1::1", + "2a00:1450::1", + ])("still allows the public storage address %s", (address) => { + expect(isPrivateAddress(address)).toBe(false); + }); + + it.each([ + "192.0.2.1", + "192.88.99.1", + "198.51.100.1", + "203.0.113.1", + "64:ff9b:1::a00:1", + "100::1", + "2001::1", + "2001:0:ffff::1", + "2001:db8::1", + "2002:a9fe:a9fe::1", + ])("blocks the reserved address %s", (address) => { + expect(isPrivateAddress(address)).toBe(true); + }); }); describe("isBlockedS3Endpoint", () => { diff --git a/apps/web/__tests__/unit/storage-fetch.test.ts b/apps/web/__tests__/unit/storage-fetch.test.ts index 685a88a900f..b48b210894c 100644 --- a/apps/web/__tests__/unit/storage-fetch.test.ts +++ b/apps/web/__tests__/unit/storage-fetch.test.ts @@ -135,6 +135,29 @@ describe("requestPublicOnly", () => { } }); + it("still refuses a body that is not text", async () => { + await expect( + requestPublicOnly(`http://localhost:${port}/`, { + method: "PUT", + body: new Blob(["x"]), + }), + ).rejects.toThrow("only send a text request body"); + }); + + it("refuses a private address written as a literal before connecting", async () => { + lookupState.allowLoopback = true; + try { + await expect( + requestPublicOnly(`http://127.0.0.1:${port}/`), + ).rejects.toBeInstanceOf(StorageFetchRefusedError); + await expect( + requestPublicOnly(`http://[::1]:${port}/`), + ).rejects.toBeInstanceOf(StorageFetchRefusedError); + } finally { + lookupState.allowLoopback = false; + } + }); + it("refuses non-http schemes", async () => { await expect(requestPublicOnly("file:///etc/hosts")).rejects.toBeInstanceOf( StorageFetchRefusedError, From dd7c267e8172a7ef96d91332c4b7879e37b9cf75 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:42:49 -0500 Subject: [PATCH 10/13] fix(web): never allow private webhook targets outside tests Private and plain http targets were allowed whenever NODE_ENV was not production, so a self-hosted install or a misconfigured server could reach internal hosts. They are now reachable only when a caller passes allowPrivate, which only tests do. A test runs the production path under seven NODE_ENV values against a real local server and sees no request. --- .../unit/developer-webhook-guard.test.ts | 13 +-- .../developer-webhook-private-targets.test.ts | 102 ++++++++++++++++++ apps/web/lib/developer-webhook-delivery.ts | 17 +-- 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 apps/web/__tests__/unit/developer-webhook-private-targets.test.ts diff --git a/apps/web/__tests__/unit/developer-webhook-guard.test.ts b/apps/web/__tests__/unit/developer-webhook-guard.test.ts index d132426670a..1173c7f4377 100644 --- a/apps/web/__tests__/unit/developer-webhook-guard.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-guard.test.ts @@ -9,12 +9,8 @@ vi.mock("@cap/env", () => ({ })); vi.mock("@cap/database", () => ({ db: () => ({}) })); -const { - allowsPrivateWebhookTargets, - checkWebhookUrl, - sendDeveloperWebhook, - webhookUrlProblem, -} = await import("@/lib/developer-webhook-delivery"); +const { checkWebhookUrl, sendDeveloperWebhook, webhookUrlProblem } = + await import("@/lib/developer-webhook-delivery"); const { isBlockedS3Endpoint, isPrivateAddress } = await import( "@/lib/s3-endpoint-policy" ); @@ -36,8 +32,9 @@ const event = { }; describe("webhook URL policy in a production build", () => { - it("is strict in production", () => { - expect(allowsPrivateWebhookTargets()).toBe(false); + it("is strict unless a caller opts in", () => { + expect(webhookUrlProblem("http://127.0.0.1/cap")).toBe("https_required"); + expect(webhookUrlProblem("http://127.0.0.1/cap", true)).toBeNull(); }); it("accepts a public https URL", () => { diff --git a/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts b/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts new file mode 100644 index 00000000000..889eb66396d --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts @@ -0,0 +1,102 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +// Whatever NODE_ENV says, the code a route or the delivery workflow runs +// (no guards passed) never reaches plain http or a private address. +const env = vi.hoisted(() => ({ + NODE_ENV: "development" as string | undefined, + buildEnv: { NEXT_PUBLIC_IS_CAP: "false" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/env", () => env); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { checkWebhookUrl, sendDeveloperWebhook, webhookUrlProblem } = + await import("@/lib/developer-webhook-delivery"); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: "{}", +}; + +let server: Server; +let port: number; +let hits = 0; + +beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe.each([ + "development", + "test", + "staging", + "preview", + "", + undefined, + "production", +])("with NODE_ENV %s", (nodeEnv) => { + it("refuses private and plain http targets on create and on delivery", async () => { + env.NODE_ENV = nodeEnv; + vi.stubEnv("NODE_ENV", nodeEnv); + hits = 0; + + expect(webhookUrlProblem(`http://127.0.0.1:${port}/`)).toBe( + "https_required", + ); + expect(await checkWebhookUrl("https://127.0.0.1/cap")).toBe( + "private_address", + ); + expect(await checkWebhookUrl("https://localhost/cap")).toBe( + "private_address", + ); + expect(await checkWebhookUrl("https://169.254.169.254/cap")).toBe( + "private_address", + ); + + for (const url of [ + `http://127.0.0.1:${port}/`, + `http://localhost:${port}/`, + "https://127.0.0.1/cap", + "https://[::1]/cap", + ]) { + const result = await sendDeveloperWebhook({ ...event, url }); + expect(result.ok).toBe(false); + } + expect(hits).toBe(0); + }); +}); + +it("reaches a local server only when a test passes allowPrivate", async () => { + hits = 0; + const result = await sendDeveloperWebhook( + { ...event, url: `http://127.0.0.1:${port}/` }, + { allowPrivate: true }, + ); + expect(result.ok).toBe(true); + expect(hits).toBe(1); +}); diff --git a/apps/web/lib/developer-webhook-delivery.ts b/apps/web/lib/developer-webhook-delivery.ts index f001287d02b..9d0dc09fe62 100644 --- a/apps/web/lib/developer-webhook-delivery.ts +++ b/apps/web/lib/developer-webhook-delivery.ts @@ -1,7 +1,6 @@ import { db } from "@cap/database"; import { decrypt } from "@cap/database/crypto"; import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; -import { NODE_ENV } from "@cap/env"; import { and, eq, isNull, sql } from "drizzle-orm"; import { WEBHOOK_DISABLE_AFTER_FAILURES, @@ -45,6 +44,7 @@ export type WebhookSendResult = }; export type WebhookGuards = { + // Tests only. Lets a local server act as the customer endpoint. allowPrivate?: boolean; isBlocked?: (url: string) => Promise; request?: (url: string, init: RequestInit) => Promise; @@ -52,10 +52,11 @@ export type WebhookGuards = { now?: () => number; }; -// A receiver on localhost is how a webhook handler is written, so a local -// build may call plain http and private addresses. Every production build, -// Cap's own or a self-hosted one, only calls public https hosts. -export const allowsPrivateWebhookTargets = () => NODE_ENV !== "production"; +// Plain http and private addresses are reachable only when a caller passes +// allowPrivate: true, and only tests do. No environment variable and no +// NODE_ENV value can turn it on, so a preview deployment, a self-hosted +// install or a misconfigured server always calls public https hosts only. +const PRIVATE_TARGETS_BY_DEFAULT = false; const URL_PROBLEM_MESSAGES: Record = { invalid_url: "The URL is not valid", @@ -74,7 +75,7 @@ export const webhookUrlProblemMessage = (problem: WebhookUrlProblem) => export function webhookUrlProblem( value: string, - allowPrivate: boolean = allowsPrivateWebhookTargets(), + allowPrivate: boolean = PRIVATE_TARGETS_BY_DEFAULT, ): WebhookUrlProblem | null { const problem = publicMediaUrlProblem(value); if (problem === "unsupported_scheme") return "https_required"; @@ -92,7 +93,7 @@ export async function checkWebhookUrl( value: string, guards: Pick = {}, ): Promise { - const allowPrivate = guards.allowPrivate ?? allowsPrivateWebhookTargets(); + const allowPrivate = guards.allowPrivate ?? PRIVATE_TARGETS_BY_DEFAULT; const problem = webhookUrlProblem(value, allowPrivate); if (problem || allowPrivate) return problem; return (await (guards.isBlocked ?? isBlockedS3Endpoint)(value)) @@ -128,7 +129,7 @@ export async function sendDeveloperWebhook( durationMs: now() - started, }); - const allowPrivate = guards.allowPrivate ?? allowsPrivateWebhookTargets(); + const allowPrivate = guards.allowPrivate ?? PRIVATE_TARGETS_BY_DEFAULT; const request = guards.request ?? (allowPrivate ? requestAnyAddress : requestPublicOnly); const abort = new AbortController(); From 697ddff8a422b2ff9ea4f1116cca3f6a9815cdfe Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:42:49 -0500 Subject: [PATCH 11/13] test(web): cover the rotate operation in the OpenAPI checks --- .../__tests__/unit/developer-webhook-docs.test.ts | 14 +++++++++----- .../unit/developer-webhook-rebinding.test.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts index 827302a4ff4..8337172baea 100644 --- a/apps/web/__tests__/unit/developer-webhook-docs.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -173,10 +173,10 @@ describe("webhooks in the OpenAPI description", () => { ) .sort(); expect(described).toEqual(served); - expect(described).toHaveLength(6); + expect(described).toHaveLength(7); }); - it("returns the signing secret from create only", () => { + it("returns the signing secret from create and rotate only", () => { const schemas = spec.components.schemas as Record< string, { properties?: object } @@ -185,13 +185,17 @@ describe("webhooks in the OpenAPI description", () => { Object.keys(schemas.WebhookEndpoint?.properties ?? {}), ).not.toContain("secret"); const text = (operation: unknown) => JSON.stringify(operation); - expect(text(paths["/api/developer/v1/webhooks"]?.post)).toContain( - "WebhookEndpointWithSecret", - ); + for (const operation of [ + paths["/api/developer/v1/webhooks"]?.post, + paths["/api/developer/v1/webhooks/{id}/rotate-secret"]?.post, + ]) { + expect(text(operation)).toContain("WebhookEndpointWithSecret"); + } for (const operation of [ paths["/api/developer/v1/webhooks"]?.get, paths["/api/developer/v1/webhooks/{id}"]?.get, paths["/api/developer/v1/webhooks/{id}"]?.patch, + paths["/api/developer/v1/webhooks/{id}/test"]?.post, ]) { expect(text(operation)).not.toContain("WebhookEndpointWithSecret"); } diff --git a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts index 4fa8ca8d681..1e2f0614cd1 100644 --- a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts @@ -1,7 +1,15 @@ import type { LookupAddress } from "node:dns"; import { createServer, request as httpRequest, type Server } from "node:http"; import type { AddressInfo } from "node:net"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; // The name below answers with a public address the first time it is asked // (the check) and a private one after that (the connection). The guard must From e42fbacc5628283dae0b3f31432ff870b56d1ab7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:54:04 -0500 Subject: [PATCH 12/13] chore(database): number the webhook endpoints migration 0058 This PR lands before the storage PR, so it takes the next number on main. The SQL is unchanged, the snapshot chains from main's 0057 and the journal when is newer than every entry on main. --- ...k_endpoints.sql => 0058_developer_webhook_endpoints.sql} | 0 .../meta/{0059_snapshot.json => 0058_snapshot.json} | 0 packages/database/migrations/meta/_journal.json | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/database/migrations/{0059_developer_webhook_endpoints.sql => 0058_developer_webhook_endpoints.sql} (100%) rename packages/database/migrations/meta/{0059_snapshot.json => 0058_snapshot.json} (100%) diff --git a/packages/database/migrations/0059_developer_webhook_endpoints.sql b/packages/database/migrations/0058_developer_webhook_endpoints.sql similarity index 100% rename from packages/database/migrations/0059_developer_webhook_endpoints.sql rename to packages/database/migrations/0058_developer_webhook_endpoints.sql diff --git a/packages/database/migrations/meta/0059_snapshot.json b/packages/database/migrations/meta/0058_snapshot.json similarity index 100% rename from packages/database/migrations/meta/0059_snapshot.json rename to packages/database/migrations/meta/0058_snapshot.json diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index deeda5de4be..b0e865f90da 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -409,10 +409,10 @@ "breakpoints": true }, { - "idx": 59, + "idx": 58, "version": "5", - "when": 1794548265583, - "tag": "0059_developer_webhook_endpoints", + "when": 1794549219180, + "tag": "0058_developer_webhook_endpoints", "breakpoints": true } ] From af92aa3ba6f90c46ee5163e8da8493246bd49dad Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:57:43 -0500 Subject: [PATCH 13/13] fix(web): let a public address through next to a reserved one The ranges added for webhooks (documentation, 6to4, Teredo, discard, local NAT64) refused a whole name when one AAAA fell inside them, which custom S3 endpoints did not do before. They now drop only their own addresses, and a name is refused only when nothing public is left. A private or loopback address anywhere in the answer still refuses the name, as on main. --- .../unit/developer-webhook-rebinding.test.ts | 48 +++++++++++ .../__tests__/unit/s3-endpoint-policy.test.ts | 37 +++++++++ apps/web/lib/s3-endpoint-policy.ts | 81 ++++++++++++++----- 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts index 1e2f0614cd1..66384ee1dbd 100644 --- a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts @@ -169,6 +169,54 @@ describe("a name that changes to a private address between check and connect", ( expect(hits).toBe(1); }); + it.each([ + ["a 6to4 AAAA", ["93.184.216.34", "2002:a00:1::1"]], + ["a Teredo AAAA", ["2001:0:4136:e378::1", "93.184.216.34"]], + ["a documentation address", ["203.0.113.9", "2606:4700::1111"]], + ])( + "hands the connection only the public address next to %s", + async (_label, answers) => { + dns.connectAnswers = answers; + const result = await new Promise<{ + error: NodeJS.ErrnoException | null; + address: unknown; + }>((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (error, address) => + resolve({ error, address }), + ), + ); + expect(result.error).toBeNull(); + const dialled = (result.address as { address: string }[]).map( + (entry) => entry.address, + ); + expect(dialled).toEqual( + answers.filter((address) => + ["93.184.216.34", "2606:4700::1111"].includes(address), + ), + ); + }, + ); + + it("refuses a name whose only addresses are reserved", async () => { + dns.connectAnswers = ["2002:a00:1::1", "192.0.2.1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); + + it("still refuses a public address next to a NAT64 form of a private one", async () => { + dns.connectAnswers = ["93.184.216.34", "64:ff9b::a00:1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); + it("checks every address the lookup returns, not only the first", async () => { dns.connectAnswers = ["93.184.216.34", "127.0.0.1"]; const error = await new Promise((resolve) => diff --git a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts index 8a87c98b8fa..c6fcf21e065 100644 --- a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts +++ b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts @@ -127,6 +127,43 @@ describe("isBlockedS3Endpoint", () => { ).resolves.toBe(true); }); + // Main refused a name when any address in its answer was private. That + // stays true for those ranges. The ranges added for webhooks only drop + // their own addresses, so the public one is still used. + it.each([ + ["a 6to4 AAAA", ["52.216.1.1", "2002:a00:1::1"]], + ["a Teredo AAAA", ["52.216.1.1", "2001:0:4136:e378::1"]], + ["a local-use NAT64 AAAA", ["52.216.1.1", "64:ff9b:1::a00:1"]], + ["a documentation address", ["203.0.113.9", "52.216.1.1"]], + ["a discard prefix AAAA", ["2600:1f18::1", "100::1"]], + ])("allows a public address next to %s", async (_label, addresses) => { + await expect( + isBlockedS3Endpoint("https://s3.example.com", resolveTo(addresses)), + ).resolves.toBe(false); + }); + + it.each([ + ["loopback", ["52.216.1.1", "127.0.0.1"]], + ["a private v4", ["52.216.1.1", "192.168.0.7"]], + ["unique local v6", ["2600:1f18::1", "fd00::1"]], + ["NAT64 of a private v4", ["52.216.1.1", "64:ff9b::a00:1"]], + ["a mapped private v4", ["52.216.1.1", "::ffff:10.0.0.1"]], + ["a reserved and a private address", ["2002:a00:1::1", "10.0.0.1"]], + ])("still blocks a public address next to %s", async (_label, addresses) => { + await expect( + isBlockedS3Endpoint("https://s3.example.com", resolveTo(addresses)), + ).resolves.toBe(true); + }); + + it("blocks a name whose only addresses are reserved", async () => { + await expect( + isBlockedS3Endpoint( + "https://s3.example.com", + resolveTo(["2002:a00:1::1", "192.0.2.1"]), + ), + ).resolves.toBe(true); + }); + it("blocks hostnames that do not resolve", async () => { await expect( isBlockedS3Endpoint("https://s3.example.com", async () => { diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts index 384f66d77e9..269848553e7 100644 --- a/apps/web/lib/s3-endpoint-policy.ts +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -15,32 +15,35 @@ for (const [network, prefix] of [ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], - ["192.0.2.0", 24], - ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], - ["198.51.100.0", 24], - ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4], ] as const) { privateRanges.addSubnet(network, prefix, "ipv4"); } +// Never dialled, but unlike the ranges above they do not condemn the whole +// name: a host with a public address and one of these (a 6to4 or Teredo +// AAAA, say) connects to the public one, as it did before these were added. +const reservedRanges = new BlockList(); + +for (const [network, prefix] of [ + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["198.51.100.0", 24], + ["203.0.113.0", 24], +] as const) { + reservedRanges.addSubnet(network, prefix, "ipv4"); +} + // BlockList already applies the IPv4 rules to IPv4-mapped addresses // (::ffff:a.b.c.d and ::ffff:7f00:1). Adding ::ffff:0:0/96 here would also // match every plain IPv4 address, so only the IPv4-compatible (::/96) and NAT64 -// (64:ff9b::/96) forms are blocked outright. 6to4 (2002::/16) and Teredo -// (2001::/32) carry an IPv4 address inside them, so a relay could deliver the -// request to a private IPv4 host that the IPv4 rules never saw. +// (64:ff9b::/96) forms are blocked outright. for (const [network, prefix] of [ ["::", 96], ["64:ff9b::", 96], - ["64:ff9b:1::", 48], - ["100::", 64], - ["2001::", 32], - ["2001:db8::", 32], - ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], @@ -49,11 +52,44 @@ for (const [network, prefix] of [ privateRanges.addSubnet(network, prefix, "ipv6"); } -export function isPrivateAddress(address: string) { +// 6to4 (2002::/16) and Teredo (2001::/32) carry an IPv4 address inside them, +// so a relay could deliver the request to a private IPv4 host that the IPv4 +// rules never saw. +for (const [network, prefix] of [ + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 32], + ["2001:db8::", 32], + ["2002::", 16], +] as const) { + reservedRanges.addSubnet(network, prefix, "ipv6"); +} + +const inRanges = (ranges: BlockList, address: string) => { const family = isIP(address); - if (family === 4) return privateRanges.check(address, "ipv4"); - if (family === 6) return privateRanges.check(address, "ipv6"); + if (family === 4) return ranges.check(address, "ipv4"); + if (family === 6) return ranges.check(address, "ipv6"); return true; +}; + +// True for any address that is never dialled. +export function isPrivateAddress(address: string) { + return inRanges(privateRanges, address) || inRanges(reservedRanges, address); +} + +// The addresses of a DNS answer that may be dialled, or null when the name is +// refused. One private address anywhere refuses the whole name. Reserved +// addresses are dropped, and the name is refused only when nothing is left. +export function dialableAddresses( + entries: readonly T[], + addressOf: (entry: T) => string, +): T[] | null { + if (entries.some((entry) => inRanges(privateRanges, addressOf(entry)))) + return null; + const usable = entries.filter( + (entry) => !inRanges(reservedRanges, addressOf(entry)), + ); + return usable.length === 0 ? null : usable; } export function shouldBlockPrivateS3Endpoints() { @@ -94,7 +130,7 @@ export async function isBlockedS3Endpoint( try { const addresses = await resolve(hostname); - return addresses.length === 0 || addresses.some(isPrivateAddress); + return dialableAddresses(addresses, (address) => address) === null; } catch { return true; } @@ -114,17 +150,22 @@ export const publicOnlyLookup: LookupFunction = ( const addresses: LookupAddress[] = Array.isArray(address) ? address : [{ address, family }]; - const blocked = addresses.find((entry) => isPrivateAddress(entry.address)); - if (blocked) { + const usable = dialableAddresses(addresses, (entry) => entry.address); + if (!usable) { + const blocked = + addresses.find((entry) => isPrivateAddress(entry.address)) ?? + addresses[0]; const blockedError: NodeJS.ErrnoException = new Error( - `Refused to connect to private address ${blocked.address}`, + `Refused to connect to private address ${blocked?.address ?? hostname}`, ); blockedError.code = "ECONNREFUSED"; callback(blockedError, address, family); return; } - callback(null, address, family); + // Only the addresses that passed are handed to the connection. + if (Array.isArray(address)) callback(null, usable, family); + else callback(null, address, family); }); };