From c2fd0b1a972330b9c48605c635042bc502a04560 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 01/58] feat(db): add agent API schema and migrations --- .../database/migrations/0035_agent_api.sql | 49 + .../migrations/0036_premium_master_chief.sql | 20 + .../migrations/meta/0035_snapshot.json | 3708 ++++++++++++++++ .../migrations/meta/0036_snapshot.json | 3834 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + packages/database/schema.ts | 111 + 6 files changed, 7736 insertions(+) create mode 100644 packages/database/migrations/0035_agent_api.sql create mode 100644 packages/database/migrations/0036_premium_master_chief.sql create mode 100644 packages/database/migrations/meta/0035_snapshot.json create mode 100644 packages/database/migrations/meta/0036_snapshot.json diff --git a/packages/database/migrations/0035_agent_api.sql b/packages/database/migrations/0035_agent_api.sql new file mode 100644 index 00000000000..97ee7113be3 --- /dev/null +++ b/packages/database/migrations/0035_agent_api.sql @@ -0,0 +1,49 @@ +CREATE TABLE `agent_api_authorization_codes` ( + `id` varchar(15) NOT NULL, + `userId` varchar(15) NOT NULL, + `codeHash` varchar(64) NOT NULL, + `codeChallenge` varchar(64) NOT NULL, + `redirectUri` varchar(512) NOT NULL, + `scopes` json NOT NULL, + `expiresAt` timestamp NOT NULL, + `consumedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `agent_api_authorization_codes_id` PRIMARY KEY(`id`), + CONSTRAINT `code_hash_idx` UNIQUE(`codeHash`) +); +--> statement-breakpoint +CREATE TABLE `agent_api_idempotency` ( + `id` varchar(15) NOT NULL, + `userId` varchar(15) NOT NULL, + `operation` varchar(64) NOT NULL, + `keyHash` varchar(64) NOT NULL, + `requestHash` varchar(64) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'pending', + `statusCode` int, + `response` json, + `expiresAt` timestamp NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `agent_api_idempotency_id` PRIMARY KEY(`id`), + CONSTRAINT `user_operation_key_idx` UNIQUE(`userId`,`operation`,`keyHash`) +); +--> statement-breakpoint +CREATE TABLE `agent_api_keys` ( + `id` varchar(15) NOT NULL, + `userId` varchar(15) NOT NULL, + `tokenHash` varchar(64) NOT NULL, + `name` varchar(100) NOT NULL DEFAULT 'Cap CLI', + `scopes` json NOT NULL, + `expiresAt` timestamp NOT NULL, + `revokedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `lastUsedAt` timestamp, + CONSTRAINT `agent_api_keys_id` PRIMARY KEY(`id`), + CONSTRAINT `token_hash_idx` UNIQUE(`tokenHash`) +); +--> statement-breakpoint +CREATE INDEX `expires_at_idx` ON `agent_api_authorization_codes` (`expiresAt`);--> statement-breakpoint +CREATE INDEX `user_created_at_idx` ON `agent_api_authorization_codes` (`userId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `expires_at_idx` ON `agent_api_idempotency` (`expiresAt`);--> statement-breakpoint +CREATE INDEX `user_created_at_idx` ON `agent_api_keys` (`userId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `expires_at_idx` ON `agent_api_keys` (`expiresAt`); \ No newline at end of file diff --git a/packages/database/migrations/0036_premium_master_chief.sql b/packages/database/migrations/0036_premium_master_chief.sql new file mode 100644 index 00000000000..321477a1001 --- /dev/null +++ b/packages/database/migrations/0036_premium_master_chief.sql @@ -0,0 +1,20 @@ +CREATE TABLE `agent_api_operations` ( + `id` varchar(15) NOT NULL, + `userId` varchar(15) NOT NULL, + `kind` varchar(32) NOT NULL, + `resourceId` varchar(15) NOT NULL, + `resultResourceId` varchar(15), + `state` varchar(16) NOT NULL DEFAULT 'queued', + `payload` json NOT NULL, + `result` json, + `errorCode` varchar(64), + `errorMessage` text, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + `completedAt` timestamp, + CONSTRAINT `agent_api_operations_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `user_created_at_idx` ON `agent_api_operations` (`userId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `state_updated_at_idx` ON `agent_api_operations` (`state`,`updatedAt`);--> statement-breakpoint +CREATE INDEX `resource_id_idx` ON `agent_api_operations` (`resourceId`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0035_snapshot.json b/packages/database/migrations/meta/0035_snapshot.json new file mode 100644 index 00000000000..72243743b1c --- /dev/null +++ b/packages/database/migrations/meta/0035_snapshot.json @@ -0,0 +1,3708 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "edb92d56-00f7-48e8-8f8e-40cf8ab37057", + "prevId": "0825cb9f-99d0-4a71-8ad9-71cb01d51053", + "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": {} + }, + "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 + } + }, + "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 + } + }, + "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_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_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 + }, + "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": { + "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 + } + }, + "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": {} + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_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": {} + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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_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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_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 + }, + "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_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "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": {} + } +} diff --git a/packages/database/migrations/meta/0036_snapshot.json b/packages/database/migrations/meta/0036_snapshot.json new file mode 100644 index 00000000000..b1086b31c86 --- /dev/null +++ b/packages/database/migrations/meta/0036_snapshot.json @@ -0,0 +1,3834 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a771184e-113d-4aca-b26b-abc85d419b88", + "prevId": "edb92d56-00f7-48e8-8f8e-40cf8ab37057", + "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 + } + }, + "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 + } + }, + "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_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_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 + }, + "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": { + "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 + } + }, + "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": {} + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_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": {} + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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_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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_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 + }, + "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_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "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": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9a2ede53150..98b25051cee 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -246,6 +246,20 @@ "when": 1782854064505, "tag": "0034_fluffy_falcon", "breakpoints": true + }, + { + "idx": 35, + "version": "5", + "when": 1784393810560, + "tag": "0035_agent_api", + "breakpoints": true + }, + { + "idx": 36, + "version": "5", + "when": 1784402487715, + "tag": "0036_premium_master_chief", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 42379cd0b5f..4a1f216792a 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1,4 +1,5 @@ import type { + Agent, AiGenerationLanguage, Comment, Folder, @@ -783,6 +784,116 @@ export const authApiKeys = mysqlTable( }), ); +export const agentApiKeys = mysqlTable( + "agent_api_keys", + { + id: nanoId("id").notNull().primaryKey(), + userId: nanoId("userId").notNull().$type(), + tokenHash: varchar("tokenHash", { length: 64 }).notNull(), + name: varchar("name", { length: 100 }).notNull().default("Cap CLI"), + scopes: json("scopes").notNull().$type(), + expiresAt: timestamp("expiresAt").notNull(), + revokedAt: timestamp("revokedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + lastUsedAt: timestamp("lastUsedAt"), + }, + (table) => [ + uniqueIndex("token_hash_idx").on(table.tokenHash), + index("user_created_at_idx").on(table.userId, table.createdAt), + index("expires_at_idx").on(table.expiresAt), + ], +); + +export const agentApiIdempotency = mysqlTable( + "agent_api_idempotency", + { + id: nanoId("id").notNull().primaryKey(), + userId: nanoId("userId").notNull().$type(), + operation: varchar("operation", { length: 64 }).notNull(), + keyHash: varchar("keyHash", { length: 64 }).notNull(), + requestHash: varchar("requestHash", { length: 64 }).notNull(), + state: varchar("state", { + length: 16, + enum: ["pending", "complete"], + }) + .notNull() + .default("pending"), + statusCode: int("statusCode"), + response: json("response").$type(), + expiresAt: timestamp("expiresAt").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [ + uniqueIndex("user_operation_key_idx").on( + table.userId, + table.operation, + table.keyHash, + ), + index("expires_at_idx").on(table.expiresAt), + ], +); + +export const agentApiAuthorizationCodes = mysqlTable( + "agent_api_authorization_codes", + { + id: nanoId("id").notNull().primaryKey(), + userId: nanoId("userId").notNull().$type(), + codeHash: varchar("codeHash", { length: 64 }).notNull(), + codeChallenge: varchar("codeChallenge", { length: 64 }).notNull(), + redirectUri: varchar("redirectUri", { length: 512 }).notNull(), + scopes: json("scopes").notNull().$type(), + expiresAt: timestamp("expiresAt").notNull(), + consumedAt: timestamp("consumedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("code_hash_idx").on(table.codeHash), + index("expires_at_idx").on(table.expiresAt), + index("user_created_at_idx").on(table.userId, table.createdAt), + ], +); + +export const agentApiOperations = mysqlTable( + "agent_api_operations", + { + id: nanoId("id").notNull().primaryKey(), + userId: nanoId("userId").notNull().$type(), + kind: varchar("kind", { + length: 32, + enum: [ + "duplicate_cap", + "delete_cap", + "import_loom", + "delete_organization", + "set_organization_domain", + "remove_organization_domain", + "verify_organization_domain", + ], + }).notNull(), + resourceId: nanoId("resourceId").notNull(), + resultResourceId: nanoIdNullable("resultResourceId"), + state: varchar("state", { + length: 16, + enum: ["queued", "running", "succeeded", "failed"], + }) + .notNull() + .default("queued"), + payload: json("payload").notNull().$type(), + result: json("result").$type(), + errorCode: varchar("errorCode", { length: 64 }), + errorMessage: text("errorMessage"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + completedAt: timestamp("completedAt"), + }, + (table) => [ + index("user_created_at_idx").on(table.userId, table.createdAt), + index("state_updated_at_idx").on(table.state, table.updatedAt), + index("resource_id_idx").on(table.resourceId), + ], +); + export const commentsRelations = relations(comments, ({ one }) => ({ author: one(users, { fields: [comments.authorId], From a4d45ed32c42fe197de72f2e50a17cd278dccc1a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 02/58] feat(email): add Resend idempotency key support --- packages/database/emails/config.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index 20ad371ef8e..ac63f968178 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -15,6 +15,7 @@ export const sendEmail = async ({ cc, replyTo, fromOverride, + idempotencyKey, }: { email: string; subject: string; @@ -25,6 +26,7 @@ export const sendEmail = async ({ cc?: string | string[]; replyTo?: string; fromOverride?: string; + idempotencyKey?: string; }) => { const r = resend(); if (!r) { @@ -40,13 +42,16 @@ export const sendEmail = async ({ from = "Cap Auth "; else from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; - return r.emails.send({ - from, - to: test ? "delivered@resend.dev" : email, - subject, - react, - scheduledAt, - cc: test ? undefined : cc, - replyTo: replyTo, - }); + return r.emails.send( + { + from, + to: test ? "delivered@resend.dev" : email, + subject, + react, + scheduledAt, + cc: test ? undefined : cc, + replyTo: replyTo, + }, + idempotencyKey ? { idempotencyKey } : undefined, + ); }; From 769a6ca0c1e89878a1dfb4d120a15b873a68b0c8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 03/58] feat(domain): add Agent API domain types --- packages/web-domain/src/Agent.ts | 2260 ++++++++++++++++++++++++++++++ packages/web-domain/src/index.ts | 1 + 2 files changed, 2261 insertions(+) create mode 100644 packages/web-domain/src/Agent.ts diff --git a/packages/web-domain/src/Agent.ts b/packages/web-domain/src/Agent.ts new file mode 100644 index 00000000000..583ecf39210 --- /dev/null +++ b/packages/web-domain/src/Agent.ts @@ -0,0 +1,2260 @@ +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiMiddleware, + HttpApiSchema, + OpenApi, +} from "@effect/platform"; +import { Context, Schema } from "effect"; +import { CommentId } from "./Comment.ts"; +import { FolderId } from "./Folder.ts"; +import { OrganisationId } from "./Organisation.ts"; +import { + PublicPageSettings, + PublicPageSettingsUpdate, +} from "./PublicCollection.ts"; +import { SpaceIdOrOrganisationId } from "./Space.ts"; +import { UploadTarget } from "./Storage.ts"; +import { UserId } from "./User.ts"; +import { VideoId } from "./Video.ts"; + +export const AgentScope = Schema.Literal( + "caps:read", + "caps:comment", + "caps:write", + "profile:read", + "profile:write", + "caps:upload", + "caps:process", + "caps:delete", + "library:read", + "library:write", + "analytics:read", + "organizations:read", + "organizations:manage", + "organizations:members", + "notifications:read", + "notifications:write", + "integrations:read", + "integrations:write", + "billing:read", + "billing:write", + "developer:read", + "developer:write", + "developer:secrets", +); +export type AgentScope = typeof AgentScope.Type; + +export class AgentPrincipal extends Context.Tag("AgentPrincipal")< + AgentPrincipal, + { + id: UserId; + email: string; + activeOrganizationId: OrganisationId; + scopes: ReadonlySet; + tokenId: string; + tokenKind: "agent" | "legacy"; + expiresAt: Date | null; + } +>() {} + +const ErrorFields = { + message: Schema.String, + retryable: Schema.Boolean, + retryAfterMs: Schema.NullOr(Schema.Number), + requestId: Schema.String, +}; + +export class AgentBadRequestError extends Schema.TaggedError()( + "AgentBadRequestError", + { + ...ErrorFields, + code: Schema.Literal("INVALID_REQUEST"), + }, + HttpApiSchema.annotations({ status: 400 }), +) {} + +export class AgentAuthenticationError extends Schema.TaggedError()( + "AgentAuthenticationError", + { + ...ErrorFields, + code: Schema.Literal("AUTH_REQUIRED", "TOKEN_EXPIRED"), + }, + HttpApiSchema.annotations({ status: 401 }), +) {} + +export class AgentForbiddenError extends Schema.TaggedError()( + "AgentForbiddenError", + { + ...ErrorFields, + code: Schema.Literal("FORBIDDEN", "PASSWORD_REQUIRED", "CONTENT_DISABLED"), + }, + HttpApiSchema.annotations({ status: 403 }), +) {} + +export class AgentNotFoundError extends Schema.TaggedError()( + "AgentNotFoundError", + { + ...ErrorFields, + code: Schema.Literal("NOT_FOUND"), + }, + HttpApiSchema.annotations({ status: 404 }), +) {} + +export class AgentNotReadyError extends Schema.TaggedError()( + "AgentNotReadyError", + { + ...ErrorFields, + code: Schema.Literal("NOT_READY"), + }, + HttpApiSchema.annotations({ status: 409 }), +) {} + +export class AgentRateLimitedError extends Schema.TaggedError()( + "AgentRateLimitedError", + { + ...ErrorFields, + code: Schema.Literal("RATE_LIMITED"), + }, + HttpApiSchema.annotations({ status: 429 }), +) {} + +export class AgentTemporaryUnavailableError extends Schema.TaggedError()( + "AgentTemporaryUnavailableError", + { + ...ErrorFields, + code: Schema.Literal("TEMPORARY_UNAVAILABLE"), + }, + HttpApiSchema.annotations({ status: 503 }), +) {} + +export const AgentApiError = Schema.Union( + AgentBadRequestError, + AgentAuthenticationError, + AgentForbiddenError, + AgentNotFoundError, + AgentNotReadyError, + AgentRateLimitedError, + AgentTemporaryUnavailableError, +); + +export class AgentConflictError extends Schema.TaggedError()( + "AgentConflictError", + { + ...ErrorFields, + code: Schema.Literal( + "CONFLICT", + "IDEMPOTENCY_CONFLICT", + "OPERATION_IN_PROGRESS", + ), + }, + HttpApiSchema.annotations({ status: 409 }), +) {} + +export class AgentApprovalRequiredError extends Schema.TaggedError()( + "AgentApprovalRequiredError", + { + ...ErrorFields, + code: Schema.Literal("APPROVAL_REQUIRED", "SECURE_INPUT_REQUIRED"), + approvalUrl: Schema.NullOr(Schema.String), + }, + HttpApiSchema.annotations({ status: 428 }), +) {} + +export class AgentHttpAuthMiddleware extends HttpApiMiddleware.Tag()( + "AgentHttpAuthMiddleware", + { + provides: AgentPrincipal, + failure: Schema.Union( + AgentAuthenticationError, + AgentTemporaryUnavailableError, + ), + }, +) {} + +export const CapabilityReason = Schema.Literal( + "CONTENT_DISABLED", + "NOT_READY", + "PASSWORD_REQUIRED", + "OWNER_ONLY", + "SCOPE_REQUIRED", +); + +export const AgentCapability = Schema.Struct({ + allowed: Schema.Boolean, + reason: Schema.NullOr(CapabilityReason), +}); + +export const AgentCapabilities = Schema.Struct({ + view: AgentCapability, + summary: AgentCapability, + chapters: AgentCapability, + transcript: AgentCapability, + comments: AgentCapability, + reactions: AgentCapability, + download: AgentCapability, + comment: AgentCapability, + react: AgentCapability, + editTitle: AgentCapability, + editVisibility: AgentCapability, + processTranscript: AgentCapability, + processAi: AgentCapability, + editTranscript: AgentCapability, + editPassword: AgentCapability, + duplicate: AgentCapability, + delete: AgentCapability, +}); + +export const AgentProcessState = Schema.Struct({ + status: Schema.Literal( + "not_started", + "queued", + "processing", + "complete", + "error", + "skipped", + "no_audio", + "unavailable", + ), + reason: Schema.NullOr(Schema.String), + retryable: Schema.Boolean, +}); + +export const AgentCapStatus = Schema.Struct({ + id: VideoId, + overall: Schema.Literal("processing", "ready", "partial", "error"), + upload: AgentProcessState, + transcript: AgentProcessState, + ai: AgentProcessState, + updatedAt: Schema.String, +}); + +export const AgentCapSummary = Schema.Struct({ + id: VideoId, + shareUrl: Schema.String, + title: Schema.String, + aiTitle: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + durationMs: Schema.NullOr(Schema.Number), + owner: Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + }), + organizationId: OrganisationId, + folderId: Schema.NullOr(FolderId), + access: Schema.Literal("owned", "shared"), + sharing: Schema.Struct({ + public: Schema.Boolean, + protected: Schema.Boolean, + }), + counts: Schema.Struct({ + comments: Schema.Number, + reactions: Schema.Number, + }), + status: AgentCapStatus, + capabilities: AgentCapabilities, +}); + +export const AgentCapsListParams = Schema.Struct({ + cursor: Schema.optional(Schema.String), + limit: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literal("all", "owned", "shared")), + organizationId: Schema.optional(Schema.String), + folderId: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), + updatedAfter: Schema.optional(Schema.String), +}); + +export const AgentCapsListResponse = Schema.Struct({ + caps: Schema.Array(AgentCapSummary), + nextCursor: Schema.NullOr(Schema.String), + requestId: Schema.String, +}); + +export const AgentVideoPath = Schema.Struct({ id: VideoId }); + +export const AgentChapter = Schema.Struct({ + title: Schema.String, + startMs: Schema.Number, +}); + +export const AgentTranscriptCue = Schema.Struct({ + startMs: Schema.Number, + endMs: Schema.Number, + text: Schema.String, +}); + +export const AgentContent = Schema.Struct({ + status: Schema.Literal("available", "not_ready", "unavailable"), + reason: Schema.NullOr(Schema.String), +}); + +export const AgentTranscriptContent = Schema.extend( + AgentContent, + Schema.Struct({ + text: Schema.NullOr(Schema.String), + cues: Schema.NullOr(Schema.Array(AgentTranscriptCue)), + }), +); + +export const AgentComment = Schema.Struct({ + id: CommentId, + videoId: VideoId, + content: Schema.String, + timestampMs: Schema.NullOr(Schema.Number), + parentCommentId: Schema.NullOr(CommentId), + createdAt: Schema.String, + updatedAt: Schema.String, + author: Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + }), +}); + +export const AgentReaction = Schema.Struct({ + id: CommentId, + videoId: VideoId, + content: Schema.String, + timestampMs: Schema.NullOr(Schema.Number), + createdAt: Schema.String, + author: Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + }), +}); + +export const AgentCapContext = Schema.Struct({ + cap: AgentCapSummary, + title: Schema.Struct({ + current: Schema.String, + ai: Schema.NullOr(Schema.String), + manuallyEdited: Schema.Boolean, + }), + summary: Schema.extend( + AgentContent, + Schema.Struct({ value: Schema.NullOr(Schema.String) }), + ), + chapters: Schema.extend( + AgentContent, + Schema.Struct({ value: Schema.NullOr(Schema.Array(AgentChapter)) }), + ), + transcript: AgentTranscriptContent, + comments: Schema.extend( + AgentContent, + Schema.Struct({ value: Schema.NullOr(Schema.Array(AgentComment)) }), + ), + reactions: Schema.extend( + AgentContent, + Schema.Struct({ value: Schema.NullOr(Schema.Array(AgentReaction)) }), + ), + views: Schema.Struct({ + status: Schema.Literal("available", "unavailable"), + aggregate: Schema.NullOr(Schema.Number), + reason: Schema.NullOr(Schema.String), + }), + metadata: Schema.Struct({ + source: Schema.String, + width: Schema.NullOr(Schema.Number), + height: Schema.NullOr(Schema.Number), + fps: Schema.NullOr(Schema.Number), + }), + requestId: Schema.String, +}); + +export const AgentTranscriptParams = Schema.Struct({ + format: Schema.optional(Schema.Literal("text", "json", "vtt")), +}); + +export const AgentTranscriptJsonResponse = Schema.Struct({ + id: VideoId, + revision: Schema.String, + cues: Schema.Array(AgentTranscriptCue), + requestId: Schema.String, +}); + +export const AgentTranscriptUpdateInput = Schema.Struct({ + expectedRevision: Schema.String, + cues: Schema.Array(AgentTranscriptCue), +}); + +export const AgentTranscriptUpdateResponse = Schema.Struct({ + id: VideoId, + revision: Schema.String, + cueCount: Schema.Number, + updatedAt: Schema.String, + requestId: Schema.String, +}); + +export const AgentProcessInput = Schema.Struct({ + target: Schema.Literal("transcript", "ai", "all"), + retry: Schema.optional(Schema.Boolean), +}); + +export const AgentProcessResponse = Schema.Struct({ + id: VideoId, + requested: Schema.Literal("transcript", "ai", "all"), + transcript: AgentProcessState, + ai: AgentProcessState, + requestId: Schema.String, +}); + +export const AgentDownloadResponse = Schema.Struct({ + fileName: Schema.String, + url: Schema.String, + expiresAt: Schema.String, + requestId: Schema.String, +}); + +export const AgentTokenRequest = Schema.Struct({ + code: Schema.String, + codeVerifier: Schema.String, + redirectUri: Schema.String, +}); + +export const AgentTokenResponse = Schema.Struct({ + accessToken: Schema.String, + tokenType: Schema.Literal("Bearer"), + expiresAt: Schema.String, + scopes: Schema.Array(AgentScope), + requestId: Schema.String, +}); + +export const AgentAuthStatusResponse = Schema.Struct({ + authenticated: Schema.Literal(true), + tokenKind: Schema.Literal("agent", "legacy"), + expiresAt: Schema.NullOr(Schema.String), + scopes: Schema.Array(AgentScope), + requestId: Schema.String, +}); + +export const AgentRevokeResponse = Schema.Struct({ + revoked: Schema.Boolean, + requestId: Schema.String, +}); + +export const AgentUnlockResponse = Schema.Struct({ + accessGrant: Schema.String, + expiresAt: Schema.String, + requestId: Schema.String, +}); + +export const AgentFeedbackInput = Schema.Struct({ + content: Schema.String, + timestampMs: Schema.optional(Schema.NullOr(Schema.Number)), +}); + +export const AgentReplyPath = Schema.Struct({ + id: VideoId, + commentId: CommentId, +}); + +export const AgentFeedbackResponse = Schema.Struct({ + id: CommentId, + videoId: VideoId, + type: Schema.Literal("text", "emoji"), + content: Schema.String, + timestampMs: Schema.NullOr(Schema.Number), + parentCommentId: Schema.NullOr(CommentId), + createdAt: Schema.String, + updatedAt: Schema.String, + author: Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + }), + requestId: Schema.String, +}); + +export const AgentCapUpdateInput = Schema.Struct({ + title: Schema.optional(Schema.String), + public: Schema.optional(Schema.Boolean), +}); + +export const AgentCapUpdateResponse = Schema.Struct({ + id: VideoId, + title: Schema.String, + public: Schema.Boolean, + updatedAt: Schema.String, + requestId: Schema.String, +}); + +export const AgentActionReason = Schema.Literal( + "SCOPE_REQUIRED", + "ROLE_REQUIRED", + "OWNER_ONLY", + "PLAN_REQUIRED", + "CONTENT_DISABLED", + "PASSWORD_REQUIRED", + "NOT_READY", + "CONFLICT", + "APPROVAL_REQUIRED", + "SECURE_INPUT_REQUIRED", + "OPERATION_IN_PROGRESS", +); + +export const AgentActionCapability = Schema.Struct({ + allowed: Schema.Boolean, + reason: Schema.NullOr(AgentActionReason), + requiredScopes: Schema.Array(AgentScope), + confirmation: Schema.Literal("none", "user", "browser", "secure_input"), + sideEffect: Schema.Literal( + "read", + "write", + "external", + "paid", + "destructive", + ), + idempotencyRequired: Schema.Boolean, + asynchronous: Schema.Boolean, +}); + +export const AgentActionCapabilities = Schema.Record({ + key: Schema.String, + value: AgentActionCapability, +}); + +export const AgentViewerSettings = Schema.Struct({ + disableSummary: Schema.NullOr(Schema.Boolean), + disableCaptions: Schema.NullOr(Schema.Boolean), + disableChapters: Schema.NullOr(Schema.Boolean), + disableReactions: Schema.NullOr(Schema.Boolean), + disableTranscript: Schema.NullOr(Schema.Boolean), + disableComments: Schema.NullOr(Schema.Boolean), + defaultPlaybackSpeed: Schema.NullOr(Schema.Number), +}); + +export const AgentMeResponse = Schema.Struct({ + id: UserId, + email: Schema.String, + name: Schema.NullOr(Schema.String), + lastName: Schema.NullOr(Schema.String), + image: Schema.NullOr(Schema.String), + activeOrganizationId: Schema.NullOr(OrganisationId), + defaultOrganizationId: Schema.NullOr(OrganisationId), + createdAt: Schema.String, + capabilities: AgentActionCapabilities, + requestId: Schema.String, +}); + +export const AgentOrganizationPath = Schema.Struct({ + organizationId: OrganisationId, +}); + +export const AgentOrganizationMemberPath = Schema.Struct({ + organizationId: OrganisationId, + memberId: Schema.String, +}); + +export const AgentOrganizationInvitePath = Schema.Struct({ + organizationId: OrganisationId, + inviteId: Schema.String, +}); + +export const AgentAiGenerationLanguage = Schema.Literal( + "auto", + "en", + "es", + "fr", + "de", + "pt", + "it", + "nl", + "pl", + "ro", + "sk", + "ru", + "tr", + "ja", + "ko", + "zh", + "ar", + "hi", + "bn", + "ta", + "te", + "mr", + "gu", + "ur", + "fa", + "he", +); + +export const AgentOrganizationSettings = Schema.Struct({ + disableSummary: Schema.NullOr(Schema.Boolean), + disableCaptions: Schema.NullOr(Schema.Boolean), + disableChapters: Schema.NullOr(Schema.Boolean), + disableReactions: Schema.NullOr(Schema.Boolean), + disableTranscript: Schema.NullOr(Schema.Boolean), + disableComments: Schema.NullOr(Schema.Boolean), + hideShareableLinkCapLogo: Schema.NullOr(Schema.Boolean), + shareableLinkUseOrganizationIcon: Schema.NullOr(Schema.Boolean), + aiGenerationLanguage: Schema.NullOr(AgentAiGenerationLanguage), + defaultPlaybackSpeed: Schema.NullOr(Schema.Number), +}); + +export const AgentOrganization = Schema.Struct({ + id: OrganisationId, + name: Schema.String, + ownerId: UserId, + role: Schema.Literal("owner", "admin", "member"), + hasProSeat: Schema.Boolean, + allowedEmailDomain: Schema.NullOr(Schema.String), + customDomain: Schema.NullOr(Schema.String), + domainVerifiedAt: Schema.NullOr(Schema.String), + icon: Schema.NullOr(Schema.String), + shareableLinkIcon: Schema.NullOr(Schema.String), + settings: AgentOrganizationSettings, + billing: Schema.Struct({ + status: Schema.NullOr(Schema.String), + plan: Schema.Literal("free", "pro"), + }), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentOrganizationsResponse = Schema.Struct({ + organizations: Schema.Array(AgentOrganization), + requestId: Schema.String, +}); + +export const AgentOrganizationResponse = Schema.Struct({ + organization: AgentOrganization, + requestId: Schema.String, +}); + +export const AgentOrganizationUpdateInput = Schema.Struct({ + name: Schema.optional(Schema.String), + allowedEmailDomain: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const AgentOrganizationCreateInput = Schema.Struct({ + name: Schema.String, +}); + +export const AgentOrganizationDomainInput = Schema.Struct({ + domain: Schema.String, +}); + +export const AgentOrganizationMemberUpdateInput = Schema.Struct({ + role: Schema.Literal("admin", "member"), +}); + +export const AgentOrganizationMemberSeatInput = Schema.Struct({ + enabled: Schema.Boolean, +}); + +export const AgentOrganizationSettingsInput = Schema.Struct({ + disableSummary: Schema.optional(Schema.Boolean), + disableCaptions: Schema.optional(Schema.Boolean), + disableChapters: Schema.optional(Schema.Boolean), + disableReactions: Schema.optional(Schema.Boolean), + disableTranscript: Schema.optional(Schema.Boolean), + disableComments: Schema.optional(Schema.Boolean), + hideShareableLinkCapLogo: Schema.optional(Schema.Boolean), + shareableLinkUseOrganizationIcon: Schema.optional(Schema.Boolean), + aiGenerationLanguage: Schema.optional(AgentAiGenerationLanguage), + defaultPlaybackSpeed: Schema.optional(Schema.Number), +}); + +export const AgentOrganizationInviteInput = Schema.Struct({ + email: Schema.String, + role: Schema.optional(Schema.Literal("admin", "member")), + sendEmail: Schema.optional(Schema.Boolean), +}); + +export const AgentMember = Schema.Struct({ + id: Schema.String, + userId: UserId, + email: Schema.String, + name: Schema.NullOr(Schema.String), + role: Schema.Literal("owner", "admin", "member"), + hasProSeat: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentMembersResponse = Schema.Struct({ + members: Schema.Array(AgentMember), + requestId: Schema.String, +}); + +export const AgentInvite = Schema.Struct({ + id: Schema.String, + invitedEmail: Schema.String, + role: Schema.Literal("owner", "admin", "member"), + status: Schema.String, + expiresAt: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentOrganizationInviteResponse = Schema.Struct({ + invite: AgentInvite, + inviteUrl: Schema.String, + emailDelivery: Schema.Literal("not_requested", "accepted"), + requestId: Schema.String, +}); + +export const AgentInvitesResponse = Schema.Struct({ + invites: Schema.Array(AgentInvite), + requestId: Schema.String, +}); + +export const AgentContainerParams = Schema.Struct({ + spaceId: Schema.optional(Schema.String), + parentId: Schema.optional(Schema.String), +}); + +export const AgentFolder = Schema.Struct({ + id: FolderId, + name: Schema.String, + color: Schema.Literal("normal", "blue", "red", "yellow"), + public: Schema.Boolean, + organizationId: OrganisationId, + createdById: UserId, + parentId: Schema.NullOr(FolderId), + spaceId: Schema.NullOr(SpaceIdOrOrganisationId), + settings: Schema.Unknown, + publicPage: Schema.NullOr(PublicPageSettings), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentFoldersResponse = Schema.Struct({ + folders: Schema.Array(AgentFolder), + requestId: Schema.String, +}); + +export const AgentSpace = Schema.Struct({ + id: SpaceIdOrOrganisationId, + name: Schema.String, + description: Schema.NullOr(Schema.String), + organizationId: OrganisationId, + createdById: UserId, + primary: Schema.Boolean, + privacy: Schema.Literal("Public", "Private"), + public: Schema.Boolean, + protected: Schema.Boolean, + icon: Schema.NullOr(Schema.String), + settings: AgentViewerSettings, + publicPage: Schema.NullOr(PublicPageSettings), + role: Schema.NullOr(Schema.Literal("admin", "member")), + counts: Schema.Struct({ + members: Schema.Number, + caps: Schema.Number, + folders: Schema.Number, + }), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentSpacesResponse = Schema.Struct({ + spaces: Schema.Array(AgentSpace), + requestId: Schema.String, +}); + +export const AgentSpacePath = Schema.Struct({ + spaceId: SpaceIdOrOrganisationId, +}); + +export const AgentSpaceMember = Schema.Struct({ + id: Schema.String, + userId: UserId, + email: Schema.String, + name: Schema.NullOr(Schema.String), + role: Schema.Literal("admin", "member"), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentSpaceMembersResponse = Schema.Struct({ + members: Schema.Array(AgentSpaceMember), + requestId: Schema.String, +}); + +export const AgentNotificationParams = Schema.Struct({ + cursor: Schema.optional(Schema.String), + limit: Schema.optional(Schema.String), + unread: Schema.optional(Schema.Literal("true", "false")), +}); + +export const AgentNotification = Schema.Struct({ + id: Schema.String, + organizationId: OrganisationId, + type: Schema.Literal("view", "comment", "reply", "reaction", "anon_view"), + data: Schema.Unknown, + videoId: Schema.NullOr(Schema.String), + readAt: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); + +export const AgentNotificationsResponse = Schema.Struct({ + notifications: Schema.Array(AgentNotification), + nextCursor: Schema.NullOr(Schema.String), + unreadCount: Schema.Number, + requestId: Schema.String, +}); + +export const AgentNotificationPreferencesResponse = Schema.Struct({ + pauseComments: Schema.Boolean, + pauseReplies: Schema.Boolean, + pauseViews: Schema.Boolean, + pauseReactions: Schema.Boolean, + pauseAnonymousViews: Schema.Boolean, + requestId: Schema.String, +}); + +export const AgentAnalyticsParams = Schema.Struct({ + organizationId: Schema.String, + spaceId: Schema.optional(Schema.String), + capId: Schema.optional(Schema.String), + range: Schema.optional(Schema.Literal("day", "week", "month", "year")), +}); + +export const AgentAnalyticsResponse = Schema.Struct({ + organizationId: OrganisationId, + spaceId: Schema.NullOr(SpaceIdOrOrganisationId), + capId: Schema.NullOr(VideoId), + range: Schema.Literal("day", "week", "month", "year"), + data: Schema.Unknown, + requestId: Schema.String, +}); + +export const AgentCapSettingsResponse = Schema.Struct({ + id: VideoId, + overrides: AgentViewerSettings, + effective: AgentViewerSettings, + inherited: Schema.Record({ + key: Schema.String, + value: Schema.Array( + Schema.Struct({ id: Schema.String, name: Schema.String }), + ), + }), + capabilities: AgentActionCapabilities, + requestId: Schema.String, +}); + +export const AgentCapSharesResponse = Schema.Struct({ + id: VideoId, + public: Schema.Boolean, + protected: Schema.Boolean, + organizations: Schema.Array( + Schema.Struct({ + organizationId: OrganisationId, + organizationName: Schema.String, + folderId: Schema.NullOr(FolderId), + sharedAt: Schema.String, + }), + ), + spaces: Schema.Array( + Schema.Struct({ + spaceId: SpaceIdOrOrganisationId, + spaceName: Schema.String, + organizationId: OrganisationId, + folderId: Schema.NullOr(FolderId), + addedAt: Schema.String, + }), + ), + capabilities: AgentActionCapabilities, + requestId: Schema.String, +}); + +export const AgentStorageIntegration = Schema.Struct({ + id: Schema.String, + provider: Schema.String, + displayName: Schema.String, + status: Schema.String, + active: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentStorageIntegrationsResponse = Schema.Struct({ + integrations: Schema.Array(AgentStorageIntegration), + requestId: Schema.String, +}); + +export const AgentS3ConfigInput = Schema.Struct({ + provider: Schema.String, + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + endpoint: Schema.String, + bucketName: Schema.String, + region: Schema.String, +}); + +export const AgentStorageProviderInput = Schema.Struct({ + provider: Schema.Literal("s3", "googleDrive"), +}); + +export const AgentGoogleDriveFolderParams = Schema.Struct({ + parentId: Schema.optional(Schema.String), +}); + +export const AgentGoogleDriveFoldersResponse = Schema.Struct({ + folders: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + driveId: Schema.NullOr(Schema.String), + driveName: Schema.NullOr(Schema.String), + }), + ), + requestId: Schema.String, +}); + +export const AgentGoogleDriveLocationInput = Schema.Struct({ + folderId: Schema.String, + folderName: Schema.optional(Schema.NullOr(Schema.String)), + driveId: Schema.optional(Schema.NullOr(Schema.String)), + driveName: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const AgentBillingResponse = Schema.Struct({ + organizationId: OrganisationId, + plan: Schema.Literal("free", "pro"), + status: Schema.NullOr(Schema.String), + managedExternally: Schema.Boolean, + seats: Schema.Struct({ total: Schema.Number, assigned: Schema.Number }), + capabilities: AgentActionCapabilities, + requestId: Schema.String, +}); + +export const AgentSubscriptionCheckoutInput = Schema.Struct({ + interval: Schema.Literal("monthly", "yearly"), + quantity: Schema.optional(Schema.Number), +}); + +export const AgentDeveloperCreditsCheckoutInput = Schema.Struct({ + amountCents: Schema.Number, +}); + +export const AgentBrowserActionResponse = Schema.Struct({ + action: Schema.String, + url: Schema.String, + requestId: Schema.String, +}); + +export const AgentDeveloperAppPath = Schema.Struct({ appId: Schema.String }); +export const AgentDeveloperAppDomainPath = Schema.Struct({ + appId: Schema.String, + domainId: Schema.String, +}); +export const AgentDeveloperVideoPath = Schema.Struct({ + appId: Schema.String, + videoId: Schema.String, +}); + +export const AgentDeveloperAppCreateInput = Schema.Struct({ + name: Schema.String, + environment: Schema.Literal("development", "production"), +}); + +export const AgentDeveloperAppUpdateInput = Schema.Struct({ + name: Schema.optional(Schema.String), + environment: Schema.optional(Schema.Literal("development", "production")), + logoUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const AgentDeveloperDomainInput = Schema.Struct({ + domain: Schema.String, +}); + +export const AgentDeveloperAutoTopUpInput = Schema.Struct({ + enabled: Schema.Boolean, + thresholdMicroCredits: Schema.optional(Schema.Number), + amountCents: Schema.optional(Schema.Number), +}); + +export const AgentDeveloperApp = Schema.Struct({ + id: Schema.String, + name: Schema.String, + environment: Schema.Literal("development", "production"), + logoUrl: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentDeveloperAppsResponse = Schema.Struct({ + apps: Schema.Array(AgentDeveloperApp), + requestId: Schema.String, +}); + +export const AgentDeveloperCredentialsResponse = Schema.Struct({ + appId: Schema.String, + publicKey: Schema.String, + secretKey: Schema.String, + requestId: Schema.String, +}); + +export const AgentDeveloperAppContextResponse = Schema.Struct({ + app: AgentDeveloperApp, + domains: Schema.Array( + Schema.Struct({ + id: Schema.String, + domain: Schema.String, + createdAt: Schema.String, + }), + ), + keys: Schema.Array( + Schema.Struct({ + id: Schema.String, + keyType: Schema.Literal("public", "secret"), + keyPrefix: Schema.String, + lastUsedAt: Schema.NullOr(Schema.String), + revokedAt: Schema.NullOr(Schema.String), + createdAt: Schema.String, + }), + ), + usage: Schema.Struct({ + videoCount: Schema.Number, + storageMinutes: Schema.Number, + }), + credits: Schema.NullOr( + Schema.Struct({ + balanceMicroCredits: Schema.Number, + autoTopUpEnabled: Schema.Boolean, + autoTopUpThresholdMicroCredits: Schema.Number, + autoTopUpAmountCents: Schema.Number, + }), + ), + requestId: Schema.String, +}); + +export const AgentDeveloperListParams = Schema.Struct({ + cursor: Schema.optional(Schema.String), + limit: Schema.optional(Schema.String), +}); + +export const AgentDeveloperVideoParams = Schema.Struct({ + userId: Schema.optional(Schema.String), + cursor: Schema.optional(Schema.String), + limit: Schema.optional(Schema.String), +}); + +export const AgentDeveloperVideo = Schema.Struct({ + id: Schema.String, + appId: Schema.String, + externalUserId: Schema.NullOr(Schema.String), + name: Schema.String, + durationSeconds: Schema.NullOr(Schema.Number), + width: Schema.NullOr(Schema.Number), + height: Schema.NullOr(Schema.Number), + fps: Schema.NullOr(Schema.Number), + transcriptionStatus: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + capabilities: AgentActionCapabilities, +}); + +export const AgentDeveloperVideosResponse = Schema.Struct({ + videos: Schema.Array(AgentDeveloperVideo), + nextCursor: Schema.NullOr(Schema.String), + requestId: Schema.String, +}); + +export const AgentDeveloperCreditTransaction = Schema.Struct({ + id: Schema.String, + type: Schema.Literal( + "topup", + "video_create", + "storage_daily", + "refund", + "adjustment", + ), + amountMicroCredits: Schema.Number, + balanceAfterMicroCredits: Schema.Number, + referenceId: Schema.NullOr(Schema.String), + referenceType: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); + +export const AgentDeveloperTransactionsResponse = Schema.Struct({ + transactions: Schema.Array(AgentDeveloperCreditTransaction), + nextCursor: Schema.NullOr(Schema.String), + requestId: Schema.String, +}); + +export const AgentMutationResponse = Schema.Struct({ + resource: Schema.Struct({ + type: Schema.String, + id: Schema.String, + revision: Schema.NullOr(Schema.String), + }), + action: Schema.String, + requestId: Schema.String, +}); + +export const AgentUploadCreateInput = Schema.Struct({ + organizationId: Schema.optional(OrganisationId), + folderId: Schema.optional(FolderId), + fileName: Schema.String, + contentType: Schema.Literal("video/mp4"), + contentLength: Schema.optional(Schema.Number), + durationSeconds: Schema.optional(Schema.Number), + width: Schema.optional(Schema.Number), + height: Schema.optional(Schema.Number), + fps: Schema.optional(Schema.Number), + title: Schema.optional(Schema.String), +}); + +export const AgentUploadCreateResponse = Schema.Struct({ + id: VideoId, + shareUrl: Schema.String, + rawFileKey: Schema.String, + upload: UploadTarget, + requestId: Schema.String, +}); + +export const AgentUploadCompleteInput = Schema.Struct({ + rawFileKey: Schema.String, + contentLength: Schema.optional(Schema.Number), +}); + +export const AgentUploadCompleteResponse = Schema.Struct({ + id: VideoId, + processing: Schema.Literal( + "started", + "already-processing", + "already-complete", + ), + requestId: Schema.String, +}); + +export const AgentLoomImportInput = Schema.Struct({ + loomUrl: Schema.String, + ownerEmail: Schema.optional(Schema.String), + spaceName: Schema.optional(Schema.String), +}); + +export const AgentProfileUpdateInput = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + lastName: Schema.optional(Schema.NullOr(Schema.String)), + defaultOrganizationId: Schema.optional(Schema.NullOr(OrganisationId)), +}); + +export const AgentImageInput = Schema.Struct({ + data: Schema.String, + contentType: Schema.String, + fileName: Schema.String, +}); + +export const AgentNotificationPreferencesInput = Schema.Struct({ + pauseComments: Schema.optional(Schema.Boolean), + pauseReplies: Schema.optional(Schema.Boolean), + pauseViews: Schema.optional(Schema.Boolean), + pauseReactions: Schema.optional(Schema.Boolean), + pauseAnonymousViews: Schema.optional(Schema.Boolean), +}); + +export const AgentNotificationsReadInput = Schema.Struct({ + ids: Schema.optional(Schema.Array(Schema.String)), + all: Schema.optional(Schema.Boolean), +}); + +export const AgentViewerSettingsInput = Schema.Struct({ + disableSummary: Schema.optional(Schema.NullOr(Schema.Boolean)), + disableCaptions: Schema.optional(Schema.NullOr(Schema.Boolean)), + disableChapters: Schema.optional(Schema.NullOr(Schema.Boolean)), + disableReactions: Schema.optional(Schema.NullOr(Schema.Boolean)), + disableTranscript: Schema.optional(Schema.NullOr(Schema.Boolean)), + disableComments: Schema.optional(Schema.NullOr(Schema.Boolean)), + defaultPlaybackSpeed: Schema.optional(Schema.NullOr(Schema.Number)), +}); + +export const AgentCapDateInput = Schema.Struct({ createdAt: Schema.String }); + +export const AgentFolderPath = Schema.Struct({ folderId: FolderId }); + +export const AgentFolderCreateInput = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.Literal("normal", "blue", "red", "yellow")), + parentId: Schema.optional(Schema.NullOr(FolderId)), + spaceId: Schema.optional(Schema.NullOr(SpaceIdOrOrganisationId)), + public: Schema.optional(Schema.Boolean), +}); + +export const AgentFolderUpdateInput = Schema.Struct({ + name: Schema.optional(Schema.String), + color: Schema.optional(Schema.Literal("normal", "blue", "red", "yellow")), + parentId: Schema.optional(Schema.NullOr(FolderId)), + public: Schema.optional(Schema.Boolean), + settings: Schema.optional(Schema.Unknown), +}); + +export const AgentSpaceCreateInput = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + privacy: Schema.optional(Schema.Literal("Public", "Private")), + public: Schema.optional(Schema.Boolean), + settings: Schema.optional(AgentViewerSettingsInput), +}); + +export const AgentSpaceUpdateInput = Schema.Struct({ + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.NullOr(Schema.String)), + privacy: Schema.optional(Schema.Literal("Public", "Private")), + public: Schema.optional(Schema.Boolean), + settings: Schema.optional(AgentViewerSettingsInput), +}); + +export const AgentCollectionPublicPageInput = Schema.extend( + PublicPageSettingsUpdate, + Schema.Struct({ public: Schema.optional(Schema.Boolean) }), +); + +export const AgentSpaceMemberPath = Schema.Struct({ + spaceId: SpaceIdOrOrganisationId, + userId: UserId, +}); + +export const AgentSpaceMemberAddInput = Schema.Struct({ + userId: UserId, + role: Schema.optional(Schema.Literal("admin", "member")), +}); + +export const AgentSpaceMemberUpdateInput = Schema.Struct({ + role: Schema.Literal("admin", "member"), +}); + +export const AgentMoveCapInput = Schema.Struct({ + container: Schema.Literal("personal", "organization", "space"), + organizationId: OrganisationId, + spaceId: Schema.optional(SpaceIdOrOrganisationId), + folderId: Schema.NullOr(FolderId), +}); + +export const AgentOrganizationSharePath = Schema.Struct({ + id: VideoId, + organizationId: OrganisationId, +}); + +export const AgentSpaceSharePath = Schema.Struct({ + id: VideoId, + spaceId: SpaceIdOrOrganisationId, +}); + +export const AgentShareInput = Schema.Struct({ + folderId: Schema.optional(Schema.NullOr(FolderId)), +}); + +export const AgentOperationPath = Schema.Struct({ operationId: Schema.String }); + +export const AgentOperationResponse = Schema.Struct({ + id: Schema.String, + kind: Schema.Literal( + "duplicate_cap", + "delete_cap", + "import_loom", + "delete_organization", + "set_organization_domain", + "remove_organization_domain", + "verify_organization_domain", + ), + state: Schema.Literal("queued", "running", "succeeded", "failed"), + resourceId: Schema.String, + resultResourceId: Schema.NullOr(Schema.String), + result: Schema.NullOr(Schema.Unknown), + error: Schema.NullOr( + Schema.Struct({ code: Schema.String, message: Schema.String }), + ), + createdAt: Schema.String, + updatedAt: Schema.String, + completedAt: Schema.NullOr(Schema.String), + requestId: Schema.String, +}); + +const addMutationErrors = < + Name extends string, + Method extends "POST" | "PATCH" | "PUT" | "DELETE", + Path extends string, +>( + endpoint: HttpApiEndpoint.HttpApiEndpoint, +) => + endpoint + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentNotReadyError) + .addError(AgentConflictError) + .addError(AgentApprovalRequiredError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware); + +const withReadErrors = ( + endpoint: HttpApiEndpoint.HttpApiEndpoint, +) => + endpoint + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentNotReadyError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware); + +export class AgentHttpApi extends HttpApiGroup.make("agent") + .add( + withReadErrors(HttpApiEndpoint.get("listCaps", "/caps")) + .setUrlParams(AgentCapsListParams) + .addSuccess(AgentCapsListResponse), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getCap", "/caps/:id")) + .setPath(AgentVideoPath) + .addSuccess(AgentCapSummary), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getContext", "/caps/:id/context")) + .setPath(AgentVideoPath) + .addSuccess(AgentCapContext), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getStatus", "/caps/:id/status")) + .setPath(AgentVideoPath) + .addSuccess(AgentCapStatus), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getTranscript", "/caps/:id/transcript")) + .setPath(AgentVideoPath) + .setUrlParams(AgentTranscriptParams), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getDownload", "/caps/:id/download")) + .setPath(AgentVideoPath) + .addSuccess(AgentDownloadResponse), + ) + .add( + HttpApiEndpoint.post("unlockCap", "/caps/:id/unlock") + .setPath(AgentVideoPath) + .addSuccess(AgentUnlockResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) + .add( + HttpApiEndpoint.post("createComment", "/caps/:id/comments") + .setPath(AgentVideoPath) + .setPayload(AgentFeedbackInput) + .addSuccess(AgentFeedbackResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentConflictError) + .addError(AgentApprovalRequiredError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) + .add( + HttpApiEndpoint.post("createReply", "/caps/:id/comments/:commentId/replies") + .setPath(AgentReplyPath) + .setPayload(AgentFeedbackInput) + .addSuccess(AgentFeedbackResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentConflictError) + .addError(AgentApprovalRequiredError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) + .add( + HttpApiEndpoint.post("createReaction", "/caps/:id/reactions") + .setPath(AgentVideoPath) + .setPayload(AgentFeedbackInput) + .addSuccess(AgentFeedbackResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentConflictError) + .addError(AgentApprovalRequiredError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) + .add( + HttpApiEndpoint.patch("updateCap", "/caps/:id") + .setPath(AgentVideoPath) + .setPayload(AgentCapUpdateInput) + .addSuccess(AgentCapUpdateResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentForbiddenError) + .addError(AgentNotFoundError) + .addError(AgentConflictError) + .addError(AgentApprovalRequiredError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) {} + +export class AgentManagementHttpApi extends HttpApiGroup.make("agentManagement") + .add( + withReadErrors(HttpApiEndpoint.get("getMe", "/me")).addSuccess( + AgentMeResponse, + ), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("listOrganizations", "/organizations"), + ).addSuccess(AgentOrganizationsResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("getOrganization", "/organizations/:organizationId"), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentOrganizationResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listOrganizationMembers", + "/organizations/:organizationId/members", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentMembersResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listOrganizationInvites", + "/organizations/:organizationId/invites", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentInvitesResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listFolders", + "/organizations/:organizationId/folders", + ), + ) + .setPath(AgentOrganizationPath) + .setUrlParams(AgentContainerParams) + .addSuccess(AgentFoldersResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listSpaces", + "/organizations/:organizationId/spaces", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentSpacesResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("listSpaceMembers", "/spaces/:spaceId/members"), + ) + .setPath(AgentSpacePath) + .addSuccess(AgentSpaceMembersResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("listNotifications", "/me/notifications"), + ) + .setUrlParams(AgentNotificationParams) + .addSuccess(AgentNotificationsResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "getNotificationPreferences", + "/me/notification-preferences", + ), + ).addSuccess(AgentNotificationPreferencesResponse), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getAnalytics", "/analytics")) + .setUrlParams(AgentAnalyticsParams) + .addSuccess(AgentAnalyticsResponse), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getCapSettings", "/caps/:id/settings")) + .setPath(AgentVideoPath) + .addSuccess(AgentCapSettingsResponse), + ) + .add( + withReadErrors(HttpApiEndpoint.get("getCapShares", "/caps/:id/shares")) + .setPath(AgentVideoPath) + .addSuccess(AgentCapSharesResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listStorageIntegrations", + "/organizations/:organizationId/storage-integrations", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentStorageIntegrationsResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listOrganizationGoogleDriveFolders", + "/organizations/:organizationId/storage/google-drive/folders", + ), + ) + .setPath(AgentOrganizationPath) + .setUrlParams(AgentGoogleDriveFolderParams) + .addSuccess(AgentGoogleDriveFoldersResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "getOrganizationBilling", + "/organizations/:organizationId/billing", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentBillingResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("listDeveloperApps", "/developer/apps"), + ).addSuccess(AgentDeveloperAppsResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "getDeveloperAppContext", + "/developer/apps/:appId/context", + ), + ) + .setPath(AgentDeveloperAppPath) + .addSuccess(AgentDeveloperAppContextResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listDeveloperVideos", + "/developer/apps/:appId/videos", + ), + ) + .setPath(AgentDeveloperAppPath) + .setUrlParams(AgentDeveloperVideoParams) + .addSuccess(AgentDeveloperVideosResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get( + "listDeveloperTransactions", + "/developer/apps/:appId/transactions", + ), + ) + .setPath(AgentDeveloperAppPath) + .setUrlParams(AgentDeveloperListParams) + .addSuccess(AgentDeveloperTransactionsResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("createDeveloperApp", "/developer/apps"), + ) + .setPayload(AgentDeveloperAppCreateInput) + .addSuccess(AgentDeveloperCredentialsResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch("updateDeveloperApp", "/developer/apps/:appId"), + ) + .setPath(AgentDeveloperAppPath) + .setPayload(AgentDeveloperAppUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del("deleteDeveloperApp", "/developer/apps/:appId"), + ) + .setPath(AgentDeveloperAppPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "addDeveloperDomain", + "/developer/apps/:appId/domains", + ), + ) + .setPath(AgentDeveloperAppPath) + .setPayload(AgentDeveloperDomainInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeDeveloperDomain", + "/developer/apps/:appId/domains/:domainId", + ), + ) + .setPath(AgentDeveloperAppDomainPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "rotateDeveloperKeys", + "/developer/apps/:appId/keys/rotate", + ), + ) + .setPath(AgentDeveloperAppPath) + .addSuccess(AgentDeveloperCredentialsResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateDeveloperAutoTopUp", + "/developer/apps/:appId/auto-top-up", + ), + ) + .setPath(AgentDeveloperAppPath) + .setPayload(AgentDeveloperAutoTopUpInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createDeveloperCreditsCheckout", + "/developer/apps/:appId/credits/checkout", + ), + ) + .setPath(AgentDeveloperAppPath) + .setPayload(AgentDeveloperCreditsCheckoutInput) + .addSuccess(AgentBrowserActionResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "deleteDeveloperVideo", + "/developer/apps/:appId/videos/:videoId", + ), + ) + .setPath(AgentDeveloperVideoPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "deleteOrganization", + "/organizations/:organizationId", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "setOrganizationDomain", + "/organizations/:organizationId/domain", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentOrganizationDomainInput) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeOrganizationDomain", + "/organizations/:organizationId/domain", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "verifyOrganizationDomain", + "/organizations/:organizationId/domain/verify", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentOperationResponse), + ) + .add( + withReadErrors( + HttpApiEndpoint.get("getOperation", "/operations/:operationId"), + ) + .setPath(AgentOperationPath) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.patch("updateMe", "/me")) + .setPayload(AgentProfileUpdateInput) + .addSuccess(AgentMeResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.put("updateProfileImage", "/me/image")) + .setPayload(AgentImageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del("removeProfileImage", "/me/image"), + ).addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("signOutAllDevices", "/me/sign-out-all"), + ).addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("openReferralPortal", "/me/referrals"), + ).addSuccess(AgentBrowserActionResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("createOrganization", "/organizations"), + ) + .setPayload(AgentOrganizationCreateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateOrganization", + "/organizations/:organizationId", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentOrganizationUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "updateOrganizationIcon", + "/organizations/:organizationId/icon", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentImageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeOrganizationIcon", + "/organizations/:organizationId/icon", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "updateShareableLinkIcon", + "/organizations/:organizationId/shareable-link-icon", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentImageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeShareableLinkIcon", + "/organizations/:organizationId/shareable-link-icon", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createSubscriptionCheckout", + "/organizations/:organizationId/billing/checkout", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentSubscriptionCheckoutInput) + .addSuccess(AgentBrowserActionResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createBillingPortal", + "/organizations/:organizationId/billing/portal", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentBrowserActionResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "updateOrganizationS3", + "/organizations/:organizationId/storage/s3", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentS3ConfigInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "testOrganizationS3", + "/organizations/:organizationId/storage/s3/test", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentS3ConfigInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeOrganizationS3", + "/organizations/:organizationId/storage/s3", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateOrganizationStorageProvider", + "/organizations/:organizationId/storage/provider", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentStorageProviderInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "connectOrganizationGoogleDrive", + "/organizations/:organizationId/storage/google-drive/connect", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentBrowserActionResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "disconnectOrganizationGoogleDrive", + "/organizations/:organizationId/storage/google-drive", + ), + ) + .setPath(AgentOrganizationPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "setOrganizationGoogleDriveLocation", + "/organizations/:organizationId/storage/google-drive/location", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentGoogleDriveLocationInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateOrganizationSettings", + "/organizations/:organizationId/settings", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentOrganizationSettingsInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createOrganizationInvite", + "/organizations/:organizationId/invites", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentOrganizationInviteInput) + .addSuccess(AgentOrganizationInviteResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "deleteOrganizationInvite", + "/organizations/:organizationId/invites/:inviteId", + ), + ) + .setPath(AgentOrganizationInvitePath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateOrganizationMemberSeat", + "/organizations/:organizationId/members/:memberId/seat", + ), + ) + .setPath(AgentOrganizationMemberPath) + .setPayload(AgentOrganizationMemberSeatInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateOrganizationMember", + "/organizations/:organizationId/members/:memberId", + ), + ) + .setPath(AgentOrganizationMemberPath) + .setPayload(AgentOrganizationMemberUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeOrganizationMember", + "/organizations/:organizationId/members/:memberId", + ), + ) + .setPath(AgentOrganizationMemberPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateNotificationPreferences", + "/me/notification-preferences", + ), + ) + .setPayload(AgentNotificationPreferencesInput) + .addSuccess(AgentNotificationPreferencesResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("markNotificationsRead", "/me/notifications/read"), + ) + .setPayload(AgentNotificationsReadInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.post("createUpload", "/uploads")) + .setPayload(AgentUploadCreateInput) + .addSuccess(AgentUploadCreateResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("completeUpload", "/uploads/:id/complete"), + ) + .setPath(AgentVideoPath) + .setPayload(AgentUploadCompleteInput) + .addSuccess(AgentUploadCompleteResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "importLoomCap", + "/organizations/:organizationId/imports/loom", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentLoomImportInput) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.post("processCap", "/caps/:id/process")) + .setPath(AgentVideoPath) + .setPayload(AgentProcessInput) + .addSuccess(AgentProcessResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put("replaceTranscript", "/caps/:id/transcript"), + ) + .setPath(AgentVideoPath) + .setPayload(AgentTranscriptUpdateInput) + .addSuccess(AgentTranscriptUpdateResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put("updateCapPassword", "/caps/:id/password"), + ) + .setPath(AgentVideoPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("duplicateCap", "/caps/:id/duplicate"), + ) + .setPath(AgentVideoPath) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.del("deleteCap", "/caps/:id")) + .setPath(AgentVideoPath) + .addSuccess(AgentOperationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch("updateCapSettings", "/caps/:id/settings"), + ) + .setPath(AgentVideoPath) + .setPayload(AgentViewerSettingsInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.patch("updateCapDate", "/caps/:id/date")) + .setPath(AgentVideoPath) + .setPayload(AgentCapDateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del("deleteFeedback", "/caps/:id/comments/:commentId"), + ) + .setPath(AgentReplyPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.patch("moveCap", "/caps/:id/location")) + .setPath(AgentVideoPath) + .setPayload(AgentMoveCapInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "shareCapWithOrganization", + "/caps/:id/shares/organizations/:organizationId", + ), + ) + .setPath(AgentOrganizationSharePath) + .setPayload(AgentShareInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeCapOrganizationShare", + "/caps/:id/shares/organizations/:organizationId", + ), + ) + .setPath(AgentOrganizationSharePath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put( + "shareCapWithSpace", + "/caps/:id/shares/spaces/:spaceId", + ), + ) + .setPath(AgentSpaceSharePath) + .setPayload(AgentShareInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeCapSpaceShare", + "/caps/:id/shares/spaces/:spaceId", + ), + ) + .setPath(AgentSpaceSharePath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createFolder", + "/organizations/:organizationId/folders", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentFolderCreateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch("updateFolder", "/folders/:folderId"), + ) + .setPath(AgentFolderPath) + .setPayload(AgentFolderUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateFolderPublicPage", + "/folders/:folderId/public-page", + ), + ) + .setPath(AgentFolderPath) + .setPayload(AgentCollectionPublicPageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put("updateFolderLogo", "/folders/:folderId/logo"), + ) + .setPath(AgentFolderPath) + .setPayload(AgentImageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del("removeFolderLogo", "/folders/:folderId/logo"), + ) + .setPath(AgentFolderPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.del("deleteFolder", "/folders/:folderId")) + .setPath(AgentFolderPath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post( + "createSpace", + "/organizations/:organizationId/spaces", + ), + ) + .setPath(AgentOrganizationPath) + .setPayload(AgentSpaceCreateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.patch("updateSpace", "/spaces/:spaceId")) + .setPath(AgentSpacePath) + .setPayload(AgentSpaceUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateSpacePublicPage", + "/spaces/:spaceId/public-page", + ), + ) + .setPath(AgentSpacePath) + .setPayload(AgentCollectionPublicPageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.put("updateSpaceLogo", "/spaces/:spaceId/logo"), + ) + .setPath(AgentSpacePath) + .setPayload(AgentImageInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del("removeSpaceLogo", "/spaces/:spaceId/logo"), + ) + .setPath(AgentSpacePath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors(HttpApiEndpoint.del("deleteSpace", "/spaces/:spaceId")) + .setPath(AgentSpacePath) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.post("addSpaceMember", "/spaces/:spaceId/members"), + ) + .setPath(AgentSpacePath) + .setPayload(AgentSpaceMemberAddInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.patch( + "updateSpaceMember", + "/spaces/:spaceId/members/:userId", + ), + ) + .setPath(AgentSpaceMemberPath) + .setPayload(AgentSpaceMemberUpdateInput) + .addSuccess(AgentMutationResponse), + ) + .add( + addMutationErrors( + HttpApiEndpoint.del( + "removeSpaceMember", + "/spaces/:spaceId/members/:userId", + ), + ) + .setPath(AgentSpaceMemberPath) + .addSuccess(AgentMutationResponse), + ) {} + +export class AgentAuthHttpApi extends HttpApiGroup.make("agentAuth") + .add( + HttpApiEndpoint.post("exchangeToken", "/auth/token") + .setPayload(AgentTokenRequest) + .addSuccess(AgentTokenResponse) + .addError(AgentBadRequestError) + .addError(AgentAuthenticationError) + .addError(AgentRateLimitedError) + .addError(AgentTemporaryUnavailableError), + ) + .add( + HttpApiEndpoint.get("getAuthStatus", "/auth/status") + .addSuccess(AgentAuthStatusResponse) + .addError(AgentAuthenticationError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) + .add( + HttpApiEndpoint.post("revokeToken", "/auth/revoke") + .addSuccess(AgentRevokeResponse) + .addError(AgentAuthenticationError) + .addError(AgentTemporaryUnavailableError) + .middleware(AgentHttpAuthMiddleware), + ) {} + +export class AgentApiContract extends HttpApi.make("cap-agent-api") + .add(AgentAuthHttpApi) + .add(AgentHttpApi) + .add(AgentManagementHttpApi) + .annotateContext( + OpenApi.annotations({ + title: "Cap Agent API", + description: + "Stable personal-library API used by Cap CLI and MCP clients", + }), + ) + .prefix("/api/v1") {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index dc7b12c7105..7cae10261fd 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -1,3 +1,4 @@ +export * as Agent from "./Agent.ts"; export * from "./Authentication.ts"; export * as Comment from "./Comment.ts"; export * from "./Database.ts"; From 9297f86c796c277661e8f2260819a4de7b7c8ece Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 04/58] feat(backend): add AgentAuth service --- packages/web-backend/src/AgentAuth.ts | 195 ++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/web-backend/src/AgentAuth.ts diff --git a/packages/web-backend/src/AgentAuth.ts b/packages/web-backend/src/AgentAuth.ts new file mode 100644 index 00000000000..bb152e0b75f --- /dev/null +++ b/packages/web-backend/src/AgentAuth.ts @@ -0,0 +1,195 @@ +import { createHash } from "node:crypto"; +import * as Db from "@cap/database/schema"; +import { Agent } from "@cap/web-domain"; +import { HttpServerRequest } from "@effect/platform"; +import { eq } from "drizzle-orm"; +import { Effect, Layer, Schema } from "effect"; +import { Database } from "./Database.ts"; + +const requestId = () => crypto.randomUUID(); + +const authRequired = () => + new Agent.AgentAuthenticationError({ + code: "AUTH_REQUIRED", + message: "A valid Cap credential is required", + retryable: false, + retryAfterMs: null, + requestId: requestId(), + }); + +const tokenExpired = () => + new Agent.AgentAuthenticationError({ + code: "TOKEN_EXPIRED", + message: "The Cap CLI credential has expired or been revoked", + retryable: false, + retryAfterMs: null, + requestId: requestId(), + }); + +const temporarilyUnavailable = () => + new Agent.AgentTemporaryUnavailableError({ + code: "TEMPORARY_UNAVAILABLE", + message: "Authentication is temporarily unavailable", + retryable: true, + retryAfterMs: null, + requestId: requestId(), + }); + +const parseBearerToken = (authorization: string | undefined) => { + if (!authorization) return null; + const [scheme, token, extra] = authorization.trim().split(/\s+/); + if (scheme?.toLowerCase() !== "bearer" || !token || extra) return null; + return token; +}; + +const hashToken = (token: string) => + createHash("sha256").update(token).digest("hex"); + +const agentScopes = new Set([ + "caps:read", + "caps:comment", + "caps:write", + "profile:read", + "profile:write", + "caps:upload", + "caps:process", + "caps:delete", + "library:read", + "library:write", + "analytics:read", + "organizations:read", + "organizations:manage", + "organizations:members", + "notifications:read", + "notifications:write", + "integrations:read", + "integrations:write", + "billing:read", + "billing:write", + "developer:read", + "developer:write", + "developer:secrets", +]); + +const parseScopes = (value: unknown) => { + if (!Array.isArray(value)) return null; + const scopes = value.filter( + (scope): scope is Agent.AgentScope => + typeof scope === "string" && agentScopes.has(scope as Agent.AgentScope), + ); + return scopes.length === value.length && scopes.includes("caps:read") + ? new Set(scopes) + : null; +}; + +export const isLegacyAgentKeySource = (source: string) => + source === "desktop" || source === "unknown"; + +export const isAgentReadAccessEnabled = (email: string) => { + if (process.env.NODE_ENV !== "production") return true; + if (process.env.CAP_AGENT_API_READ_ENABLED !== "true") return false; + const allowlist = new Set( + (process.env.CAP_AGENT_API_ALLOWLIST ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean), + ); + return allowlist.has(email.trim().toLowerCase()); +}; + +export const AgentHttpAuthMiddlewareLive = Layer.effect( + Agent.AgentHttpAuthMiddleware, + Effect.gen(function* () { + const database = yield* Database; + + return Agent.AgentHttpAuthMiddleware.of( + Effect.gen(function* () { + const headers = yield* HttpServerRequest.schemaHeaders( + Schema.Struct({ authorization: Schema.optional(Schema.String) }), + ); + const token = parseBearerToken(headers.authorization); + if (!token) return yield* authRequired(); + + if (/^cap_cli_[A-Za-z0-9_-]{43}$/.test(token)) { + const [row] = yield* database.use((db) => + db + .select({ + tokenId: Db.agentApiKeys.id, + scopes: Db.agentApiKeys.scopes, + expiresAt: Db.agentApiKeys.expiresAt, + revokedAt: Db.agentApiKeys.revokedAt, + id: Db.users.id, + email: Db.users.email, + activeOrganizationId: Db.users.activeOrganizationId, + }) + .from(Db.agentApiKeys) + .innerJoin(Db.users, eq(Db.agentApiKeys.userId, Db.users.id)) + .where(eq(Db.agentApiKeys.tokenHash, hashToken(token))) + .limit(1), + ); + if (!row) return yield* authRequired(); + if (row.revokedAt || row.expiresAt.getTime() <= Date.now()) { + return yield* tokenExpired(); + } + const scopes = parseScopes(row.scopes); + if (!scopes) return yield* authRequired(); + if (!isAgentReadAccessEnabled(row.email)) { + return yield* temporarilyUnavailable(); + } + return Agent.AgentPrincipal.of({ + id: row.id, + email: row.email, + activeOrganizationId: row.activeOrganizationId, + scopes, + tokenId: row.tokenId, + tokenKind: "agent", + expiresAt: row.expiresAt, + }); + } + + if (token.length !== 36) return yield* authRequired(); + + const [row] = yield* database.use((db) => + db + .select({ + id: Db.users.id, + email: Db.users.email, + activeOrganizationId: Db.users.activeOrganizationId, + source: Db.authApiKeys.source, + }) + .from(Db.authApiKeys) + .innerJoin(Db.users, eq(Db.authApiKeys.userId, Db.users.id)) + .where(eq(Db.authApiKeys.id, token)) + .limit(1), + ); + if (!row) return yield* authRequired(); + if (!isLegacyAgentKeySource(row.source)) return yield* authRequired(); + if (!isAgentReadAccessEnabled(row.email)) { + return yield* temporarilyUnavailable(); + } + + return Agent.AgentPrincipal.of({ + id: row.id, + email: row.email, + activeOrganizationId: row.activeOrganizationId, + scopes: new Set([ + "caps:read", + "caps:comment", + "caps:write", + ]), + tokenId: token, + tokenKind: "legacy", + expiresAt: null, + }); + }).pipe( + Effect.provideService(Database, database), + Effect.catchTags({ + DatabaseError: () => Effect.fail(temporarilyUnavailable()), + ParseError: () => Effect.fail(authRequired()), + }), + ), + ); + }), +); + +export const parseAgentBearerToken = parseBearerToken; From e7ab79e5a8c4f34dc480553a8d9dfca22d0611d0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 05/58] feat(backend): add AgentManagement service --- packages/web-backend/src/AgentManagement.ts | 812 ++++++++++++++++++++ packages/web-backend/src/index.ts | 2 + 2 files changed, 814 insertions(+) create mode 100644 packages/web-backend/src/AgentManagement.ts diff --git a/packages/web-backend/src/AgentManagement.ts b/packages/web-backend/src/AgentManagement.ts new file mode 100644 index 00000000000..3f90af430de --- /dev/null +++ b/packages/web-backend/src/AgentManagement.ts @@ -0,0 +1,812 @@ +import * as Db from "@cap/database/schema"; +import { + type Folder, + type Organisation, + Policy, + type Space, + type User, + type Video, +} from "@cap/web-domain"; +import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm"; +import { Effect } from "effect"; +import { Database } from "./Database.ts"; + +type OrganizationRole = "owner" | "admin" | "member"; + +const normalizedRole = (role: string): OrganizationRole => { + const value = role.toLowerCase(); + return value === "owner" || value === "admin" ? value : "member"; +}; + +export class AgentManagement extends Effect.Service()( + "AgentManagement", + { + effect: Effect.gen(function* () { + const database = yield* Database; + + const getMembership = Effect.fn("AgentManagement.getMembership")( + function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + const [membership] = yield* database.use((db) => + db + .select({ + id: Db.organizationMembers.id, + role: Db.organizationMembers.role, + hasProSeat: Db.organizationMembers.hasProSeat, + }) + .from(Db.organizationMembers) + .innerJoin( + Db.organizations, + eq(Db.organizationMembers.organizationId, Db.organizations.id), + ) + .where( + and( + eq(Db.organizationMembers.userId, userId), + eq(Db.organizationMembers.organizationId, organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1), + ); + if (!membership) return yield* new Policy.PolicyDeniedError(); + return { + ...membership, + role: normalizedRole(membership.role), + }; + }, + ); + + const requireOrganizationManager = Effect.fn( + "AgentManagement.requireOrganizationManager", + )(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + const membership = yield* getMembership(userId, organizationId); + if (membership.role === "member") { + return yield* new Policy.PolicyDeniedError(); + } + return membership; + }); + + const getSpaceAccess = Effect.fn("AgentManagement.getSpaceAccess")( + function* ( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, + ) { + const [row] = yield* database.use((db) => + db + .select({ + organizationId: Db.spaces.organizationId, + createdById: Db.spaces.createdById, + privacy: Db.spaces.privacy, + memberRole: Db.spaceMembers.role, + organizationRole: Db.organizationMembers.role, + }) + .from(Db.spaces) + .innerJoin( + Db.organizationMembers, + and( + eq( + Db.organizationMembers.organizationId, + Db.spaces.organizationId, + ), + eq(Db.organizationMembers.userId, userId), + ), + ) + .leftJoin( + Db.spaceMembers, + and( + eq(Db.spaceMembers.spaceId, Db.spaces.id), + eq(Db.spaceMembers.userId, userId), + ), + ) + .where(eq(Db.spaces.id, spaceId)) + .limit(1), + ); + if (!row) return yield* new Policy.PolicyDeniedError(); + const organizationRole = normalizedRole(row.organizationRole); + const canView = + row.privacy === "Public" || + row.createdById === userId || + row.memberRole !== null || + organizationRole !== "member"; + if (!canView) return yield* new Policy.PolicyDeniedError(); + return { + ...row, + organizationRole, + canManage: + row.createdById === userId || + row.memberRole === "admin" || + organizationRole !== "member", + }; + }, + ); + + const listOrganizations = Effect.fn("AgentManagement.listOrganizations")( + function* (userId: User.UserId) { + return yield* database.use((db) => + db + .select({ + id: Db.organizations.id, + name: Db.organizations.name, + ownerId: Db.organizations.ownerId, + role: Db.organizationMembers.role, + hasProSeat: Db.organizationMembers.hasProSeat, + allowedEmailDomain: Db.organizations.allowedEmailDomain, + customDomain: Db.organizations.customDomain, + domainVerifiedAt: Db.organizations.domainVerified, + settings: Db.organizations.settings, + icon: Db.organizations.iconUrl, + shareableLinkIcon: Db.organizations.shareableLinkIconUrl, + ownerSubscriptionStatus: Db.users.stripeSubscriptionStatus, + ownerThirdPartySubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + createdAt: Db.organizations.createdAt, + updatedAt: Db.organizations.updatedAt, + }) + .from(Db.organizationMembers) + .innerJoin( + Db.organizations, + eq(Db.organizationMembers.organizationId, Db.organizations.id), + ) + .innerJoin(Db.users, eq(Db.organizations.ownerId, Db.users.id)) + .where( + and( + eq(Db.organizationMembers.userId, userId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .orderBy(Db.organizations.name, Db.organizations.id), + ); + }, + ); + + const getOrganization = Effect.fn("AgentManagement.getOrganization")( + function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + yield* getMembership(userId, organizationId); + const organizations = yield* listOrganizations(userId); + const organization = organizations.find( + (item) => item.id === organizationId, + ); + if (!organization) return yield* new Policy.PolicyDeniedError(); + return organization; + }, + ); + + const getFolderAccess = Effect.fn("AgentManagement.getFolderAccess")( + function* (userId: User.UserId, folderId: Folder.FolderId) { + const [folder] = yield* database.use((db) => + db + .select() + .from(Db.folders) + .where(eq(Db.folders.id, folderId)) + .limit(1), + ); + if (!folder) return yield* new Policy.PolicyDeniedError(); + const membership = yield* getMembership( + userId, + folder.organizationId, + ); + if (folder.spaceId === null) { + if (folder.createdById !== userId) { + return yield* new Policy.PolicyDeniedError(); + } + return { folder, canManage: true }; + } + if (folder.spaceId === folder.organizationId) { + return { folder, canManage: membership.role !== "member" }; + } + const access = yield* getSpaceAccess(userId, folder.spaceId); + return { folder, canManage: access.canManage }; + }, + ); + + return { + getAccount: Effect.fn("AgentManagement.getAccount")(function* ( + userId: User.UserId, + ) { + const [user] = yield* database.use((db) => + db + .select({ + id: Db.users.id, + email: Db.users.email, + name: Db.users.name, + lastName: Db.users.lastName, + image: Db.users.image, + activeOrganizationId: Db.users.activeOrganizationId, + defaultOrganizationId: Db.users.defaultOrgId, + createdAt: Db.users.created_at, + }) + .from(Db.users) + .where(eq(Db.users.id, userId)) + .limit(1), + ); + if (!user) return yield* new Policy.PolicyDeniedError(); + return user; + }), + + listOrganizations, + getOrganization, + getFolderAccess, + + listMembers: Effect.fn("AgentManagement.listMembers")(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + yield* getMembership(userId, organizationId); + return yield* database.use((db) => + db + .select({ + id: Db.organizationMembers.id, + userId: Db.organizationMembers.userId, + email: Db.users.email, + name: Db.users.name, + role: Db.organizationMembers.role, + hasProSeat: Db.organizationMembers.hasProSeat, + createdAt: Db.organizationMembers.createdAt, + updatedAt: Db.organizationMembers.updatedAt, + }) + .from(Db.organizationMembers) + .innerJoin( + Db.users, + eq(Db.organizationMembers.userId, Db.users.id), + ) + .where(eq(Db.organizationMembers.organizationId, organizationId)) + .orderBy(Db.users.email, Db.organizationMembers.id), + ); + }), + + listInvites: Effect.fn("AgentManagement.listInvites")(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + yield* requireOrganizationManager(userId, organizationId); + return yield* database.use((db) => + db + .select() + .from(Db.organizationInvites) + .where(eq(Db.organizationInvites.organizationId, organizationId)) + .orderBy( + desc(Db.organizationInvites.createdAt), + Db.organizationInvites.id, + ), + ); + }), + + listFolders: Effect.fn("AgentManagement.listFolders")(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + spaceId: Space.SpaceIdOrOrganisationId | null, + parentId: Folder.FolderId | null | undefined, + ) { + yield* getMembership(userId, organizationId); + if (spaceId && spaceId !== organizationId) { + const access = yield* getSpaceAccess(userId, spaceId); + if (access.organizationId !== organizationId) { + return yield* new Policy.PolicyDeniedError(); + } + } + return yield* database.use((db) => + db + .select() + .from(Db.folders) + .where( + and( + eq(Db.folders.organizationId, organizationId), + spaceId + ? eq(Db.folders.spaceId, spaceId) + : and( + isNull(Db.folders.spaceId), + eq(Db.folders.createdById, userId), + ), + parentId === undefined + ? undefined + : parentId === null + ? isNull(Db.folders.parentId) + : eq(Db.folders.parentId, parentId), + ), + ) + .orderBy(Db.folders.name, Db.folders.id), + ); + }), + + listSpaces: Effect.fn("AgentManagement.listSpaces")(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + const membership = yield* getMembership(userId, organizationId); + return yield* database.use((db) => + db + .select({ + id: Db.spaces.id, + name: Db.spaces.name, + description: Db.spaces.description, + organizationId: Db.spaces.organizationId, + createdById: Db.spaces.createdById, + primary: Db.spaces.primary, + privacy: Db.spaces.privacy, + public: Db.spaces.public, + hasPassword: + sql`${Db.spaces.password} IS NOT NULL`.mapWith( + Boolean, + ), + icon: Db.spaces.iconUrl, + settings: Db.spaces.settings, + role: Db.spaceMembers.role, + memberCount: sql`( + SELECT COUNT(*) FROM ${Db.spaceMembers} + WHERE ${Db.spaceMembers.spaceId} = ${Db.spaces.id} + )`.mapWith(Number), + capCount: sql`( + SELECT COUNT(*) FROM ${Db.spaceVideos} + WHERE ${Db.spaceVideos.spaceId} = ${Db.spaces.id} + )`.mapWith(Number), + folderCount: sql`( + SELECT COUNT(*) FROM ${Db.folders} + WHERE ${Db.folders.spaceId} = ${Db.spaces.id} + )`.mapWith(Number), + createdAt: Db.spaces.createdAt, + updatedAt: Db.spaces.updatedAt, + }) + .from(Db.spaces) + .leftJoin( + Db.spaceMembers, + and( + eq(Db.spaceMembers.spaceId, Db.spaces.id), + eq(Db.spaceMembers.userId, userId), + ), + ) + .where( + and( + eq(Db.spaces.organizationId, organizationId), + membership.role === "member" + ? or( + eq(Db.spaces.privacy, "Public"), + eq(Db.spaces.createdById, userId), + eq(Db.spaceMembers.userId, userId), + ) + : undefined, + ), + ) + .orderBy(Db.spaces.name, Db.spaces.id), + ); + }), + + listSpaceMembers: Effect.fn("AgentManagement.listSpaceMembers")( + function* ( + userId: User.UserId, + spaceId: Space.SpaceIdOrOrganisationId, + ) { + yield* getSpaceAccess(userId, spaceId); + return yield* database.use((db) => + db + .select({ + id: Db.spaceMembers.id, + userId: Db.spaceMembers.userId, + email: Db.users.email, + name: Db.users.name, + role: Db.spaceMembers.role, + createdAt: Db.spaceMembers.createdAt, + updatedAt: Db.spaceMembers.updatedAt, + }) + .from(Db.spaceMembers) + .innerJoin(Db.users, eq(Db.spaceMembers.userId, Db.users.id)) + .where(eq(Db.spaceMembers.spaceId, spaceId)) + .orderBy(Db.users.email, Db.spaceMembers.id), + ); + }, + ), + + listNotifications: Effect.fn("AgentManagement.listNotifications")( + function* ( + userId: User.UserId, + limit: number, + cursor: { createdAt: Date; id: string } | null, + unread: boolean | null, + ) { + const rows = yield* database.use((db) => + db + .select() + .from(Db.notifications) + .where( + and( + eq(Db.notifications.recipientId, userId), + unread === true + ? isNull(Db.notifications.readAt) + : undefined, + cursor + ? or( + lt(Db.notifications.createdAt, cursor.createdAt), + and( + eq(Db.notifications.createdAt, cursor.createdAt), + lt(Db.notifications.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy( + desc(Db.notifications.createdAt), + desc(Db.notifications.id), + ) + .limit(limit + 1), + ); + const [{ count }] = yield* database.use((db) => + db + .select({ count: sql`COUNT(*)`.mapWith(Number) }) + .from(Db.notifications) + .where( + and( + eq(Db.notifications.recipientId, userId), + isNull(Db.notifications.readAt), + ), + ), + ); + return { rows, unreadCount: count ?? 0 }; + }, + ), + + getNotificationPreferences: Effect.fn( + "AgentManagement.getNotificationPreferences", + )(function* (userId: User.UserId) { + const [user] = yield* database.use((db) => + db + .select({ preferences: Db.users.preferences }) + .from(Db.users) + .where(eq(Db.users.id, userId)) + .limit(1), + ); + if (!user) return yield* new Policy.PolicyDeniedError(); + return user.preferences?.notifications ?? null; + }), + + listStorageIntegrations: Effect.fn( + "AgentManagement.listStorageIntegrations", + )(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + yield* getMembership(userId, organizationId); + return yield* database.use((db) => + db + .select({ + id: Db.storageIntegrations.id, + provider: Db.storageIntegrations.provider, + displayName: Db.storageIntegrations.displayName, + status: Db.storageIntegrations.status, + active: Db.storageIntegrations.active, + createdAt: Db.storageIntegrations.createdAt, + updatedAt: Db.storageIntegrations.updatedAt, + }) + .from(Db.storageIntegrations) + .where(eq(Db.storageIntegrations.organizationId, organizationId)) + .orderBy( + desc(Db.storageIntegrations.active), + Db.storageIntegrations.displayName, + ), + ); + }), + + getBilling: Effect.fn("AgentManagement.getBilling")(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + ) { + const membership = yield* requireOrganizationManager( + userId, + organizationId, + ); + const organization = yield* getOrganization(userId, organizationId); + const [{ total, assigned }] = yield* database.use((db) => + db + .select({ + total: sql`COUNT(*)`.mapWith(Number), + assigned: + sql`SUM(CASE WHEN ${Db.organizationMembers.hasProSeat} THEN 1 ELSE 0 END)`.mapWith( + Number, + ), + }) + .from(Db.organizationMembers) + .where(eq(Db.organizationMembers.organizationId, organizationId)), + ); + return { + membership, + organization, + totalSeats: total ?? 0, + assignedSeats: assigned ?? 0, + }; + }), + + listDeveloperApps: Effect.fn("AgentManagement.listDeveloperApps")( + function* (userId: User.UserId) { + return yield* database.use((db) => + db + .select({ + id: Db.developerApps.id, + name: Db.developerApps.name, + environment: Db.developerApps.environment, + logoUrl: Db.developerApps.logoUrl, + createdAt: Db.developerApps.createdAt, + updatedAt: Db.developerApps.updatedAt, + }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.ownerId, userId), + isNull(Db.developerApps.deletedAt), + ), + ) + .orderBy(Db.developerApps.name, Db.developerApps.id), + ); + }, + ), + + getDeveloperAppContext: Effect.fn( + "AgentManagement.getDeveloperAppContext", + )(function* (userId: User.UserId, appId: string) { + const [app] = yield* database.use((db) => + db + .select({ + id: Db.developerApps.id, + name: Db.developerApps.name, + environment: Db.developerApps.environment, + logoUrl: Db.developerApps.logoUrl, + createdAt: Db.developerApps.createdAt, + updatedAt: Db.developerApps.updatedAt, + }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, appId), + eq(Db.developerApps.ownerId, userId), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1), + ); + if (!app) return yield* new Policy.PolicyDeniedError(); + const [domains, keys, usageRows, creditsRows, snapshotRows] = + yield* database.use((db) => + Promise.all([ + db + .select({ + id: Db.developerAppDomains.id, + domain: Db.developerAppDomains.domain, + createdAt: Db.developerAppDomains.createdAt, + }) + .from(Db.developerAppDomains) + .where(eq(Db.developerAppDomains.appId, appId)) + .orderBy(Db.developerAppDomains.domain), + db + .select({ + id: Db.developerApiKeys.id, + keyType: Db.developerApiKeys.keyType, + keyPrefix: Db.developerApiKeys.keyPrefix, + lastUsedAt: Db.developerApiKeys.lastUsedAt, + revokedAt: Db.developerApiKeys.revokedAt, + createdAt: Db.developerApiKeys.createdAt, + }) + .from(Db.developerApiKeys) + .where(eq(Db.developerApiKeys.appId, appId)) + .orderBy(desc(Db.developerApiKeys.createdAt)), + db + .select({ + videoCount: sql`COUNT(*)`.mapWith(Number), + }) + .from(Db.developerVideos) + .where( + and( + eq(Db.developerVideos.appId, appId), + isNull(Db.developerVideos.deletedAt), + ), + ), + db + .select({ + balanceMicroCredits: + Db.developerCreditAccounts.balanceMicroCredits, + autoTopUpEnabled: + Db.developerCreditAccounts.autoTopUpEnabled, + autoTopUpThresholdMicroCredits: + Db.developerCreditAccounts.autoTopUpThresholdMicroCredits, + autoTopUpAmountCents: + Db.developerCreditAccounts.autoTopUpAmountCents, + }) + .from(Db.developerCreditAccounts) + .where(eq(Db.developerCreditAccounts.appId, appId)) + .limit(1), + db + .select({ + totalDurationMinutes: + Db.developerDailyStorageSnapshots.totalDurationMinutes, + }) + .from(Db.developerDailyStorageSnapshots) + .where(eq(Db.developerDailyStorageSnapshots.appId, appId)) + .orderBy(desc(Db.developerDailyStorageSnapshots.snapshotDate)) + .limit(1), + ]), + ); + return { + app, + domains, + keys, + videoCount: usageRows[0]?.videoCount ?? 0, + storageMinutes: snapshotRows[0]?.totalDurationMinutes ?? 0, + credits: creditsRows[0] ?? null, + }; + }), + + listDeveloperVideos: Effect.fn("AgentManagement.listDeveloperVideos")( + function* ( + userId: User.UserId, + appId: string, + limit: number, + cursor: { createdAt: Date; id: string } | null, + externalUserId: string | null, + ) { + return yield* database.use((db) => + db + .select({ + id: Db.developerVideos.id, + appId: Db.developerVideos.appId, + externalUserId: Db.developerVideos.externalUserId, + name: Db.developerVideos.name, + duration: Db.developerVideos.duration, + width: Db.developerVideos.width, + height: Db.developerVideos.height, + fps: Db.developerVideos.fps, + transcriptionStatus: Db.developerVideos.transcriptionStatus, + createdAt: Db.developerVideos.createdAt, + updatedAt: Db.developerVideos.updatedAt, + }) + .from(Db.developerVideos) + .innerJoin( + Db.developerApps, + eq(Db.developerVideos.appId, Db.developerApps.id), + ) + .where( + and( + eq(Db.developerVideos.appId, appId), + eq(Db.developerApps.ownerId, userId), + isNull(Db.developerApps.deletedAt), + isNull(Db.developerVideos.deletedAt), + externalUserId + ? eq(Db.developerVideos.externalUserId, externalUserId) + : undefined, + cursor + ? or( + lt(Db.developerVideos.createdAt, cursor.createdAt), + and( + eq(Db.developerVideos.createdAt, cursor.createdAt), + lt(Db.developerVideos.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy( + desc(Db.developerVideos.createdAt), + desc(Db.developerVideos.id), + ) + .limit(limit + 1), + ); + }, + ), + + listDeveloperTransactions: Effect.fn( + "AgentManagement.listDeveloperTransactions", + )(function* ( + userId: User.UserId, + appId: string, + limit: number, + cursor: { createdAt: Date; id: string } | null, + ) { + return yield* database.use((db) => + db + .select({ + id: Db.developerCreditTransactions.id, + type: Db.developerCreditTransactions.type, + amountMicroCredits: + Db.developerCreditTransactions.amountMicroCredits, + balanceAfterMicroCredits: + Db.developerCreditTransactions.balanceAfterMicroCredits, + referenceId: Db.developerCreditTransactions.referenceId, + referenceType: Db.developerCreditTransactions.referenceType, + createdAt: Db.developerCreditTransactions.createdAt, + }) + .from(Db.developerCreditTransactions) + .innerJoin( + Db.developerCreditAccounts, + eq( + Db.developerCreditTransactions.accountId, + Db.developerCreditAccounts.id, + ), + ) + .innerJoin( + Db.developerApps, + eq(Db.developerCreditAccounts.appId, Db.developerApps.id), + ) + .where( + and( + eq(Db.developerApps.id, appId), + eq(Db.developerApps.ownerId, userId), + isNull(Db.developerApps.deletedAt), + cursor + ? or( + lt( + Db.developerCreditTransactions.createdAt, + cursor.createdAt, + ), + and( + eq( + Db.developerCreditTransactions.createdAt, + cursor.createdAt, + ), + lt(Db.developerCreditTransactions.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy( + desc(Db.developerCreditTransactions.createdAt), + desc(Db.developerCreditTransactions.id), + ) + .limit(limit + 1), + ); + }), + + assertAnalyticsAccess: Effect.fn( + "AgentManagement.assertAnalyticsAccess", + )(function* ( + userId: User.UserId, + organizationId: Organisation.OrganisationId, + spaceId: Space.SpaceIdOrOrganisationId | null, + capId: Video.VideoId | null, + ) { + const organization = yield* getOrganization(userId, organizationId); + const isPro = + organization.ownerSubscriptionStatus === "active" || + organization.ownerSubscriptionStatus === "trialing" || + organization.ownerThirdPartySubscriptionId !== null; + if (!isPro) { + return yield* new Policy.PolicyDeniedError({ + reason: "Cap Pro is required for analytics", + }); + } + if (spaceId) { + const access = yield* getSpaceAccess(userId, spaceId); + if (access.organizationId !== organizationId) { + return yield* new Policy.PolicyDeniedError(); + } + } + if (capId) { + const [video] = yield* database.use((db) => + db + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, capId), + eq(Db.videos.orgId, organizationId), + ), + ) + .limit(1), + ); + if (!video) return yield* new Policy.PolicyDeniedError(); + } + return organization; + }), + + getMembership, + requireOrganizationManager, + getSpaceAccess, + }; + }), + dependencies: [Database.Default], + }, +) {} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 87263a4a8b3..ddfbb957d47 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -1,3 +1,5 @@ +export * from "./AgentAuth.ts"; +export * from "./AgentManagement.ts"; export * from "./Auth.ts"; export * from "./Aws.ts"; export * from "./Database.ts"; From 423490c255c782fb17ee6761d3cf18059fd85f81 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 06/58] feat(web): add agent auth utilities --- apps/web/lib/agent-auth.ts | 166 +++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 apps/web/lib/agent-auth.ts diff --git a/apps/web/lib/agent-auth.ts b/apps/web/lib/agent-auth.ts new file mode 100644 index 00000000000..e494f77f4df --- /dev/null +++ b/apps/web/lib/agent-auth.ts @@ -0,0 +1,166 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Agent } from "@cap/web-domain"; + +export const agentScopes = [ + "caps:read", + "caps:comment", + "caps:write", + "profile:read", + "profile:write", + "caps:upload", + "caps:process", + "caps:delete", + "library:read", + "library:write", + "analytics:read", + "organizations:read", + "organizations:manage", + "organizations:members", + "notifications:read", + "notifications:write", + "integrations:read", + "integrations:write", + "billing:read", + "billing:write", + "developer:read", + "developer:write", + "developer:secrets", +] as const satisfies readonly Agent.AgentScope[]; + +export type AgentAuthorizationRequest = { + clientId: "cap-cli"; + redirectUri: string; + state: string; + codeChallenge: string; + scopes: Agent.AgentScope[]; +}; + +type AuthorizationParams = Record; + +const single = (value: string | string[] | undefined) => + typeof value === "string" ? value : null; + +export const isAgentLoopbackRedirectUri = (value: string) => { + try { + const url = new URL(value); + return ( + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "[::1]") && + url.port.length > 0 && + url.pathname === "/callback" && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ); + } catch { + return false; + } +}; + +export const isAgentState = (value: string) => + value.length >= 43 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value); + +export const isAgentCodeChallenge = (value: string) => + value.length === 43 && /^[A-Za-z0-9_-]+$/.test(value); + +export const isAgentCodeVerifier = (value: string) => + value.length >= 43 && value.length <= 128 && /^[A-Za-z0-9._~-]+$/.test(value); + +export const parseAgentScopes = (value: string) => { + const requested = value.split(" ").filter(Boolean); + if ( + requested.length === 0 || + new Set(requested).size !== requested.length || + requested.some( + (scope) => !agentScopes.includes(scope as Agent.AgentScope), + ) || + !requested.includes("caps:read") + ) { + return null; + } + return agentScopes.filter((scope) => requested.includes(scope)); +}; + +export const parseAgentAuthorizationRequest = ( + params: AuthorizationParams, +): AgentAuthorizationRequest | null => { + const clientId = single(params.client_id); + const redirectUri = single(params.redirect_uri); + const responseType = single(params.response_type); + const state = single(params.state); + const codeChallenge = single(params.code_challenge); + const codeChallengeMethod = single(params.code_challenge_method); + const scope = single(params.scope); + const scopes = scope ? parseAgentScopes(scope) : null; + if ( + clientId !== "cap-cli" || + !redirectUri || + !isAgentLoopbackRedirectUri(redirectUri) || + responseType !== "code" || + !state || + !isAgentState(state) || + !codeChallenge || + !isAgentCodeChallenge(codeChallenge) || + codeChallengeMethod !== "S256" || + !scopes + ) { + return null; + } + return { clientId, redirectUri, state, codeChallenge, scopes }; +}; + +export const hashAgentSecret = (value: string) => + createHash("sha256").update(value).digest("hex"); + +export const verifyAgentCodeChallenge = ( + verifier: string, + challenge: string, +) => { + if (!isAgentCodeVerifier(verifier) || !isAgentCodeChallenge(challenge)) { + return false; + } + const actual = Buffer.from( + createHash("sha256").update(verifier).digest("base64url"), + ); + const expected = Buffer.from(challenge); + return actual.length === expected.length && timingSafeEqual(actual, expected); +}; + +export const createAgentAuthorizationCode = () => + randomBytes(32).toString("base64url"); + +export const createAgentAccessToken = () => + `cap_cli_${randomBytes(32).toString("base64url")}`; + +export const buildAgentCallbackUrl = ( + redirectUri: string, + params: { state: string; code?: string; error?: "access_denied" }, +) => { + if (!isAgentLoopbackRedirectUri(redirectUri) || !isAgentState(params.state)) { + return null; + } + if ((params.code ? 1 : 0) + (params.error ? 1 : 0) !== 1) return null; + const url = new URL(redirectUri); + url.searchParams.set("state", params.state); + if (params.code) url.searchParams.set("code", params.code); + if (params.error) url.searchParams.set("error", params.error); + return url.toString(); +}; + +export const isAgentReadAccessEnabled = (input: { + nodeEnv: string | undefined; + enabled: string | undefined; + allowlist: string | undefined; + email: string; +}) => { + if (input.nodeEnv !== "production") return true; + if (input.enabled !== "true") return false; + const allowedEmails = new Set( + (input.allowlist ?? "") + .split(",") + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), + ); + return allowedEmails.has(input.email.trim().toLowerCase()); +}; From 202b743d2e81578ec4fa065a43e99d1038177a6a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 07/58] feat(web): add agent token exchange utilities --- apps/web/lib/agent-token.ts | 163 ++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 apps/web/lib/agent-token.ts diff --git a/apps/web/lib/agent-token.ts b/apps/web/lib/agent-token.ts new file mode 100644 index 00000000000..4f3034be0e8 --- /dev/null +++ b/apps/web/lib/agent-token.ts @@ -0,0 +1,163 @@ +import "server-only"; + +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { Database } from "@cap/web-backend"; +import { Agent } from "@cap/web-domain"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { Effect } from "effect"; +import { + createAgentAccessToken, + hashAgentSecret, + isAgentCodeVerifier, + isAgentLoopbackRedirectUri, + verifyAgentCodeChallenge, +} from "./agent-auth"; + +const getAffectedRows = (result: unknown) => { + if (Array.isArray(result)) { + return ( + (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0 + ); + } + return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0; +}; + +const commonError = (requestId: string, message: string) => ({ + message, + retryable: false, + retryAfterMs: null, + requestId, +}); + +const invalidGrant = (requestId: string) => + new Agent.AgentBadRequestError({ + ...commonError(requestId, "The authorization grant is invalid"), + code: "INVALID_REQUEST", + }); + +const expiredGrant = (requestId: string) => + new Agent.AgentAuthenticationError({ + ...commonError(requestId, "The authorization grant has expired"), + code: "TOKEN_EXPIRED", + }); + +export const exchangeAgentAuthorizationCode = Effect.fn( + "Agent.exchangeAuthorizationCode", +)(function* ( + payload: (typeof Agent.AgentTokenRequest)["Type"], + requestId: string, +) { + if ( + payload.code.length < 32 || + payload.code.length > 128 || + !isAgentCodeVerifier(payload.codeVerifier) || + !isAgentLoopbackRedirectUri(payload.redirectUri) + ) { + return yield* invalidGrant(requestId); + } + + const database = yield* Database; + const now = new Date(); + const result = yield* database.use((db) => + db.transaction(async (tx) => { + const [grant] = await tx + .select() + .from(Db.agentApiAuthorizationCodes) + .where( + eq( + Db.agentApiAuthorizationCodes.codeHash, + hashAgentSecret(payload.code), + ), + ) + .limit(1); + if (!grant || grant.redirectUri !== payload.redirectUri) { + return { state: "invalid" as const }; + } + if (grant.consumedAt || grant.expiresAt.getTime() <= now.getTime()) { + return { state: "expired" as const }; + } + if ( + !verifyAgentCodeChallenge(payload.codeVerifier, grant.codeChallenge) + ) { + return { state: "invalid" as const }; + } + + const consumed = await tx + .update(Db.agentApiAuthorizationCodes) + .set({ consumedAt: now }) + .where( + and( + eq(Db.agentApiAuthorizationCodes.id, grant.id), + isNull(Db.agentApiAuthorizationCodes.consumedAt), + gt(Db.agentApiAuthorizationCodes.expiresAt, now), + ), + ); + if (getAffectedRows(consumed) !== 1) { + return { state: "expired" as const }; + } + + const accessToken = createAgentAccessToken(); + const expiresAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + await tx.insert(Db.agentApiKeys).values({ + id: nanoId(), + userId: grant.userId, + tokenHash: hashAgentSecret(accessToken), + scopes: grant.scopes, + expiresAt, + }); + return { + state: "issued" as const, + accessToken, + expiresAt, + scopes: grant.scopes, + }; + }), + ); + + if (result.state === "invalid") return yield* invalidGrant(requestId); + if (result.state === "expired") return yield* expiredGrant(requestId); + return { + accessToken: result.accessToken, + tokenType: "Bearer" as const, + expiresAt: result.expiresAt.toISOString(), + scopes: result.scopes, + requestId, + }; +}); + +export const revokeAgentAccessToken = Effect.fn("Agent.revokeAccessToken")( + function* (requestId: string) { + const principal = yield* Agent.AgentPrincipal; + if (principal.tokenKind !== "agent") { + return { revoked: false, requestId }; + } + const database = yield* Database; + const result = yield* database.use((db) => + db + .update(Db.agentApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(Db.agentApiKeys.id, principal.tokenId), + eq(Db.agentApiKeys.userId, principal.id), + isNull(Db.agentApiKeys.revokedAt), + ), + ), + ); + return { revoked: getAffectedRows(result) === 1, requestId }; + }, +); + +export const getAgentAuthStatus = Effect.fn("Agent.getAuthStatus")(function* ( + requestId: string, +) { + const principal = yield* Agent.AgentPrincipal; + return { + authenticated: true as const, + tokenKind: principal.tokenKind, + expiresAt: principal.expiresAt?.toISOString() ?? null, + scopes: Array.from(principal.scopes), + requestId, + }; +}); From 1177eccac995ef85249650c69ff057f17fca117d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 08/58] feat(web): add agent access grant helper --- apps/web/lib/agent-access-grant.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/web/lib/agent-access-grant.ts diff --git a/apps/web/lib/agent-access-grant.ts b/apps/web/lib/agent-access-grant.ts new file mode 100644 index 00000000000..ff665bce2ba --- /dev/null +++ b/apps/web/lib/agent-access-grant.ts @@ -0,0 +1,70 @@ +import "server-only"; + +import { decrypt, encrypt } from "@cap/database/crypto"; +import type { User, Video } from "@cap/web-domain"; + +type AgentAccessGrantPayload = { + videoId: string; + userId: string; + passwordHash: string; + expiresAt: string; +}; + +export const validateAgentAccessGrantPayload = ( + value: unknown, + videoId: Video.VideoId, + userId: User.UserId, + now = Date.now(), +) => { + if (!value || typeof value !== "object") return null; + const payload = value as Partial; + if ( + payload.videoId !== videoId || + payload.userId !== userId || + typeof payload.passwordHash !== "string" || + payload.passwordHash.length < 32 || + typeof payload.expiresAt !== "string" + ) { + return null; + } + const expiresAt = Date.parse(payload.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= now) return null; + return { + passwordHash: payload.passwordHash, + expiresAt: new Date(expiresAt), + }; +}; + +export const createAgentAccessGrant = async ( + videoId: Video.VideoId, + userId: User.UserId, + passwordHash: string, +) => { + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + const grant = await encrypt( + JSON.stringify({ + videoId, + userId, + passwordHash, + expiresAt: expiresAt.toISOString(), + } satisfies AgentAccessGrantPayload), + ); + return { grant, expiresAt }; +}; + +export const readAgentAccessGrant = async ( + grant: string | undefined, + videoId: Video.VideoId, + userId: User.UserId, +) => { + if (!grant || grant.length > 4_096) return null; + try { + return validateAgentAccessGrantPayload( + JSON.parse(await decrypt(grant)), + videoId, + userId, + ); + } catch { + return null; + } +}; From f9d0fb1acaeb387a9567ba3c5d791e0b322b3e82 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 09/58] feat(web): add agent API response helpers --- apps/web/lib/agent-api.ts | 425 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 apps/web/lib/agent-api.ts diff --git a/apps/web/lib/agent-api.ts b/apps/web/lib/agent-api.ts new file mode 100644 index 00000000000..39d1ae4fa83 --- /dev/null +++ b/apps/web/lib/agent-api.ts @@ -0,0 +1,425 @@ +import { createHash } from "node:crypto"; +import type { VideoMetadata } from "@cap/database/types"; +import type { Agent } from "@cap/web-domain"; + +export type AgentCursor = { + updatedAt: string; + id: string; +}; + +export type AgentCapabilityInput = { + isOwner: boolean; + hasReadScope: boolean; + hasCommentScope: boolean; + hasWriteScope: boolean; + hasProcessScope: boolean; + hasDeleteScope: boolean; + passwordRequired: boolean; + transcriptStatus: string | null; + hasSummary: boolean; + hasChapters: boolean; + settings: { + disableSummary: boolean; + disableChapters: boolean; + disableTranscript: boolean; + disableComments: boolean; + disableReactions: boolean; + }; +}; + +const capability = ( + allowed: boolean, + reason: (typeof Agent.CapabilityReason)["Type"] | null = null, +): (typeof Agent.AgentCapability)["Type"] => ({ allowed, reason }); + +export const parseAgentLimit = (value: string | undefined) => { + if (!value) return 50; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return null; + return Math.min(parsed, 100); +}; + +const parseAgentUtcDate = (value: string) => { + const match = value.match( + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/, + ); + if (!match) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return undefined; + const normalized = `${match[1]}.${(match[2] ?? "").padEnd(3, "0")}Z`; + return parsed.toISOString() === normalized ? parsed : undefined; +}; + +export const parseAgentDate = (value: string | undefined) => { + if (!value) return null; + return parseAgentUtcDate(value); +}; + +export const encodeAgentCursor = (cursor: AgentCursor) => + Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); + +export const decodeAgentCursor = (value: string | undefined) => { + if (!value) return null; + if (value.length > 1_024) return undefined; + try { + const parsed: unknown = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const { updatedAt, id } = parsed as Record; + if ( + typeof updatedAt !== "string" || + typeof id !== "string" || + !/^[A-Za-z0-9_-]{5,128}$/.test(id) + ) { + return undefined; + } + const date = parseAgentUtcDate(updatedAt); + if (!date) return undefined; + return { updatedAt: date.toISOString(), id } satisfies AgentCursor; + } catch { + return undefined; + } +}; + +const parseVttTimestamp = (value: string) => { + const match = value.trim().match(/^(\d{1,3}):(\d{2}):(\d{2})[.,](\d{3})$/); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const milliseconds = Number(match[4]); + if (minutes > 59 || seconds > 59) return null; + return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds; +}; + +const normalizeCueText = (lines: string[]) => + lines + .join("\n") + .replace(/<[^>]+>/g, "") + .trim(); + +export const parseAgentVtt = ( + vtt: string, +): (typeof Agent.AgentTranscriptCue)["Type"][] => { + const lines = vtt.replace(/\r\n?/g, "\n").split("\n"); + const cues: (typeof Agent.AgentTranscriptCue)["Type"][] = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]?.trim() ?? ""; + if (!line.includes("-->")) continue; + const [startValue, rawEnd] = line.split("-->"); + const endValue = rawEnd?.trim().split(/\s+/)[0]; + if (!startValue || !endValue) continue; + const startMs = parseVttTimestamp(startValue); + const endMs = parseVttTimestamp(endValue); + if (startMs === null || endMs === null || endMs < startMs) continue; + + const textLines: string[] = []; + for (let cueIndex = index + 1; cueIndex < lines.length; cueIndex++) { + const cueLine = lines[cueIndex] ?? ""; + if (!cueLine.trim()) { + index = cueIndex; + break; + } + if (cueLine.includes("-->")) { + index = cueIndex - 1; + break; + } + textLines.push(cueLine); + if (cueIndex === lines.length - 1) index = cueIndex; + } + + const text = normalizeCueText(textLines); + if (text) cues.push({ startMs, endMs, text }); + } + + return cues; +}; + +export const transcriptTextFromCues = ( + cues: ReadonlyArray<(typeof Agent.AgentTranscriptCue)["Type"]>, +) => cues.map((cue) => cue.text).join("\n"); + +const formatVttTimestamp = (milliseconds: number) => { + const hours = Math.floor(milliseconds / 3_600_000); + const minutes = Math.floor((milliseconds % 3_600_000) / 60_000); + const seconds = Math.floor((milliseconds % 60_000) / 1_000); + const remainder = milliseconds % 1_000; + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${remainder.toString().padStart(3, "0")}`; +}; + +export const renderAgentVtt = ( + cues: ReadonlyArray<(typeof Agent.AgentTranscriptCue)["Type"]>, +) => + [ + "WEBVTT", + "", + ...cues.flatMap((cue, index) => [ + String(index + 1), + `${formatVttTimestamp(cue.startMs)} --> ${formatVttTimestamp(cue.endMs)}`, + cue.text.replace(/\s+/g, " ").trim(), + "", + ]), + ].join("\n"); + +export const agentTranscriptRevision = (vtt: string) => + createHash("sha256").update(vtt, "utf8").digest("hex"); + +export const normalizeAgentMetadata = (metadata: unknown) => { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return {} as VideoMetadata; + } + return metadata as VideoMetadata; +}; + +export const agentCapabilities = ({ + isOwner, + hasReadScope, + hasCommentScope, + hasWriteScope, + hasProcessScope, + hasDeleteScope, + passwordRequired, + transcriptStatus, + hasSummary, + hasChapters, + settings, +}: AgentCapabilityInput): (typeof Agent.AgentCapabilities)["Type"] => { + if (passwordRequired) { + const locked = capability(false, "PASSWORD_REQUIRED"); + return { + view: locked, + summary: locked, + chapters: locked, + transcript: locked, + comments: locked, + reactions: locked, + download: locked, + comment: locked, + react: locked, + editTitle: isOwner && hasWriteScope ? capability(true) : locked, + editVisibility: isOwner && hasWriteScope ? capability(true) : locked, + processTranscript: isOwner && hasProcessScope ? capability(true) : locked, + processAi: isOwner && hasProcessScope ? capability(true) : locked, + editTranscript: isOwner && hasWriteScope ? capability(true) : locked, + editPassword: isOwner && hasWriteScope ? capability(true) : locked, + duplicate: isOwner && hasWriteScope ? capability(true) : locked, + delete: isOwner && hasDeleteScope ? capability(true) : locked, + }; + } + + const summary = !hasReadScope + ? capability(false, "SCOPE_REQUIRED") + : settings.disableSummary + ? capability(false, "CONTENT_DISABLED") + : hasSummary + ? capability(true) + : capability(false, "NOT_READY"); + const chapters = !hasReadScope + ? capability(false, "SCOPE_REQUIRED") + : settings.disableChapters + ? capability(false, "CONTENT_DISABLED") + : hasChapters + ? capability(true) + : capability(false, "NOT_READY"); + const transcript = !hasReadScope + ? capability(false, "SCOPE_REQUIRED") + : settings.disableTranscript + ? capability(false, "CONTENT_DISABLED") + : transcriptStatus === "COMPLETE" + ? capability(true) + : capability(false, "NOT_READY"); + const comments = settings.disableComments + ? capability(false, "CONTENT_DISABLED") + : capability(true); + const reactions = settings.disableReactions + ? capability(false, "CONTENT_DISABLED") + : capability(true); + + return { + view: hasReadScope ? capability(true) : capability(false, "SCOPE_REQUIRED"), + summary, + chapters, + transcript, + comments, + reactions, + download: hasReadScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + comment: settings.disableComments + ? capability(false, "CONTENT_DISABLED") + : hasCommentScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + react: settings.disableReactions + ? capability(false, "CONTENT_DISABLED") + : hasCommentScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + editTitle: !isOwner + ? capability(false, "OWNER_ONLY") + : hasWriteScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + editVisibility: !isOwner + ? capability(false, "OWNER_ONLY") + : hasWriteScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + processTranscript: !isOwner + ? capability(false, "OWNER_ONLY") + : settings.disableTranscript + ? capability(false, "CONTENT_DISABLED") + : hasProcessScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + processAi: !isOwner + ? capability(false, "OWNER_ONLY") + : !hasProcessScope + ? capability(false, "SCOPE_REQUIRED") + : transcriptStatus === "COMPLETE" + ? capability(true) + : capability(false, "NOT_READY"), + editTranscript: !isOwner + ? capability(false, "OWNER_ONLY") + : settings.disableTranscript + ? capability(false, "CONTENT_DISABLED") + : !hasWriteScope + ? capability(false, "SCOPE_REQUIRED") + : transcriptStatus === "COMPLETE" + ? capability(true) + : capability(false, "NOT_READY"), + editPassword: !isOwner + ? capability(false, "OWNER_ONLY") + : hasWriteScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + duplicate: !isOwner + ? capability(false, "OWNER_ONLY") + : hasWriteScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + delete: !isOwner + ? capability(false, "OWNER_ONLY") + : hasDeleteScope + ? capability(true) + : capability(false, "SCOPE_REQUIRED"), + }; +}; + +const processState = ( + status: (typeof Agent.AgentProcessState)["Type"]["status"], + reason: string | null = null, + retryable = false, +): (typeof Agent.AgentProcessState)["Type"] => ({ + status, + reason, + retryable, +}); + +const normalizeTranscriptStatus = (status: string | null) => { + switch (status) { + case "PROCESSING": + return processState("processing"); + case "COMPLETE": + return processState("complete"); + case "ERROR": + return processState("error", "TRANSCRIPTION_FAILED", true); + case "SKIPPED": + return processState("skipped", "TRANSCRIPTION_SKIPPED"); + case "NO_AUDIO": + return processState("no_audio", "NO_AUDIO"); + default: + return processState("not_started", "NOT_REQUESTED"); + } +}; + +const normalizeAiStatus = (status: string | undefined) => { + switch (status) { + case "QUEUED": + return processState("queued"); + case "PROCESSING": + return processState("processing"); + case "COMPLETE": + return processState("complete"); + case "ERROR": + return processState("error", "AI_GENERATION_FAILED", true); + case "SKIPPED": + return processState("skipped", "AI_GENERATION_SKIPPED"); + default: + return processState("not_started", "NOT_REQUESTED"); + } +}; + +const normalizeUploadStatus = (input: { + phase: string | null; + error: string | null; +}) => { + switch (input.phase) { + case "uploading": + case "processing": + case "generating_thumbnail": + return processState("processing"); + case "error": + return processState("error", input.error ?? "PROCESSING_FAILED", true); + case "complete": + case null: + return processState("complete"); + default: + return processState("unavailable", "UNKNOWN_PROCESSING_STATE"); + } +}; + +export const agentStatus = ({ + id, + updatedAt, + transcriptionStatus, + aiGenerationStatus, + uploadPhase, + uploadError, +}: { + id: (typeof Agent.AgentCapStatus)["Type"]["id"]; + updatedAt: Date; + transcriptionStatus: string | null; + aiGenerationStatus?: string; + uploadPhase: string | null; + uploadError: string | null; +}): (typeof Agent.AgentCapStatus)["Type"] => { + const upload = normalizeUploadStatus({ + phase: uploadPhase, + error: uploadError, + }); + const transcript = normalizeTranscriptStatus(transcriptionStatus); + const ai = normalizeAiStatus(aiGenerationStatus); + const states = [upload.status, transcript.status, ai.status]; + const overall = states.includes("error") + ? "error" + : states.some((state) => state === "processing" || state === "queued") + ? "processing" + : transcript.status === "complete" && + (ai.status === "complete" || ai.status === "skipped") + ? "ready" + : "partial"; + + return { + id, + overall, + upload, + transcript, + ai, + updatedAt: updatedAt.toISOString(), + }; +}; + +export const safeDownloadFileName = (title: string) => { + const normalized = title + .normalize("NFKD") + .replace(/[^a-zA-Z0-9._ -]+/g, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, 120); + return `${normalized || "cap-recording"}.mp4`; +}; From 73c60e80a54bc32d4b75e10ee38a38652a906385 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 10/58] feat(web): add agent write operations --- apps/web/lib/agent-write.ts | 640 ++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 apps/web/lib/agent-write.ts diff --git a/apps/web/lib/agent-write.ts b/apps/web/lib/agent-write.ts new file mode 100644 index 00000000000..01d628db12e --- /dev/null +++ b/apps/web/lib/agent-write.ts @@ -0,0 +1,640 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { Database, type DbClient } from "@cap/web-backend"; +import { + Agent, + Comment, + type DatabaseError, + type Video, +} from "@cap/web-domain"; +import { and, eq, sql } from "drizzle-orm"; +import { Effect, Schedule, Schema } from "effect"; +import { revalidatePath } from "next/cache"; +import { createNotification } from "@/lib/Notification"; + +type TransactionCallback = Parameters[0]; +type Transaction = Parameters[0]; + +export type AgentMutationOutcome = + | { state: "success"; response: A } + | { state: "not_found" } + | { state: "forbidden" } + | { state: "conflict" }; + +type IdempotencyInput = { + userId: Agent.AgentPrincipal["Type"]["id"]; + operation: string; + key: string; + requestHash: string; + expiresAt: Date; +}; + +const getAffectedRows = (result: unknown) => { + if (Array.isArray(result)) { + return ( + (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0 + ); + } + return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0; +}; + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); + +export const isAgentIdempotencyKey = (value: string | undefined) => + typeof value === "string" && + value.length >= 8 && + value.length <= 128 && + /^[A-Za-z0-9._-]+$/.test(value); + +export const isAgentWriteAccessEnabled = (input: { + nodeEnv: string | undefined; + enabled: string | undefined; +}) => input.nodeEnv !== "production" || input.enabled === "true"; + +const acquireIdempotency = async (tx: Transaction, input: IdempotencyInput) => { + const keyHash = hash(input.key); + const candidateId = nanoId(); + await tx + .insert(Db.agentApiIdempotency) + .values({ + id: candidateId, + userId: input.userId, + operation: input.operation, + keyHash, + requestHash: input.requestHash, + expiresAt: input.expiresAt, + }) + .onDuplicateKeyUpdate({ + set: { keyHash: sql`${Db.agentApiIdempotency.keyHash}` }, + }); + const [record] = await tx + .select() + .from(Db.agentApiIdempotency) + .where( + and( + eq(Db.agentApiIdempotency.userId, input.userId), + eq(Db.agentApiIdempotency.operation, input.operation), + eq(Db.agentApiIdempotency.keyHash, keyHash), + ), + ) + .limit(1) + .for("update"); + if (!record) return { state: "unavailable" as const }; + if (record.expiresAt.getTime() <= Date.now()) { + await tx + .update(Db.agentApiIdempotency) + .set({ + requestHash: input.requestHash, + state: "pending", + statusCode: null, + response: null, + expiresAt: input.expiresAt, + }) + .where(eq(Db.agentApiIdempotency.id, record.id)); + return { state: "new" as const, record }; + } + if (record.requestHash !== input.requestHash) { + return { state: "conflict" as const }; + } + if (record.state === "complete") { + return { state: "replay" as const, record }; + } + if (record.id !== candidateId) { + return { state: "pending" as const }; + } + return { state: "new" as const, record }; +}; + +const completeIdempotency = async ( + tx: Transaction, + id: string, + response: unknown, +) => { + const result = await tx + .update(Db.agentApiIdempotency) + .set({ state: "complete", statusCode: 200, response }) + .where( + and( + eq(Db.agentApiIdempotency.id, id), + eq(Db.agentApiIdempotency.state, "pending"), + ), + ); + if (getAffectedRows(result) !== 1) { + throw new Error("Could not complete idempotent mutation"); + } +}; + +const releaseIdempotency = (tx: Transaction, id: string) => + tx + .delete(Db.agentApiIdempotency) + .where( + and( + eq(Db.agentApiIdempotency.id, id), + eq(Db.agentApiIdempotency.state, "pending"), + ), + ); + +const commonError = (requestId: string, message: string) => ({ + message, + retryable: false, + retryAfterMs: null, + requestId, +}); + +const badRequest = (requestId: string, message: string) => + new Agent.AgentBadRequestError({ + ...commonError(requestId, message), + code: "INVALID_REQUEST", + }); + +const notFound = (requestId: string) => + new Agent.AgentNotFoundError({ + ...commonError(requestId, "The requested resource was not found"), + code: "NOT_FOUND", + }); + +const temporarilyUnavailable = (requestId: string) => + new Agent.AgentTemporaryUnavailableError({ + message: "The mutation could not be completed", + code: "TEMPORARY_UNAVAILABLE", + retryable: true, + retryAfterMs: 500, + requestId, + }); + +const idempotencyConflict = (requestId: string) => + new Agent.AgentConflictError({ + ...commonError( + requestId, + "Idempotency key was already used for a different request", + ), + code: "IDEMPOTENCY_CONFLICT", + }); + +export const runAgentMutation = (input: { + principal: Agent.AgentPrincipal["Type"]; + operation: string; + idempotencyKey: string; + request: unknown; + requestId: string; + decodeReplay: (value: unknown) => A; + execute: (tx: Transaction) => Promise>>; +}): Effect.Effect< + A, + | Agent.AgentBadRequestError + | Agent.AgentConflictError + | Agent.AgentTemporaryUnavailableError + | Agent.AgentNotFoundError + | Agent.AgentForbiddenError + | DatabaseError, + Database +> => + Effect.gen(function* () { + if (!isAgentIdempotencyKey(input.idempotencyKey)) { + return yield* badRequest( + input.requestId, + "A valid Idempotency-Key header is required", + ); + } + const database = yield* Database; + const requestHash = hash( + JSON.stringify({ + tokenId: input.principal.tokenId, + operation: input.operation, + request: input.request, + }), + ); + const result = yield* database.use((db) => + db.transaction(async (tx) => { + const idempotency = await acquireIdempotency(tx, { + userId: input.principal.id, + operation: input.operation, + key: input.idempotencyKey, + requestHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + if (idempotency.state === "conflict") { + return { state: "idempotency_conflict" as const }; + } + if (idempotency.state === "unavailable") { + return { state: "unavailable" as const }; + } + if (idempotency.state === "pending") { + return { state: "pending" as const }; + } + if (idempotency.state === "replay") { + return { + state: "replay" as const, + response: input.decodeReplay(idempotency.record.response), + }; + } + const outcome = await input.execute(tx); + if (outcome.state !== "success") { + await releaseIdempotency(tx, idempotency.record.id); + return outcome; + } + await completeIdempotency(tx, idempotency.record.id, outcome.response); + return outcome; + }), + ); + if (result.state === "idempotency_conflict") { + return yield* idempotencyConflict(input.requestId); + } + if (result.state === "unavailable" || result.state === "pending") { + return yield* temporarilyUnavailable(input.requestId); + } + if (result.state === "not_found") return yield* notFound(input.requestId); + if (result.state === "forbidden") { + return yield* new Agent.AgentForbiddenError({ + ...commonError(input.requestId, "Access is not allowed"), + code: "FORBIDDEN", + }); + } + if (result.state === "conflict") { + return yield* new Agent.AgentConflictError({ + ...commonError( + input.requestId, + "The resource changed; refresh and retry", + ), + code: "CONFLICT", + }); + } + if (result.state === "success" || result.state === "replay") { + return result.response; + } + return yield* temporarilyUnavailable(input.requestId); + }); + +export const runAgentExternalMutation = (input: { + principal: Agent.AgentPrincipal["Type"]; + operation: string; + idempotencyKey: string; + request: unknown; + requestId: string; + decodeReplay: (value: unknown) => A; + execute: (providerIdempotencyKey: string) => Effect.Effect; +}): Effect.Effect< + A, + | E + | Agent.AgentBadRequestError + | Agent.AgentConflictError + | Agent.AgentTemporaryUnavailableError + | DatabaseError, + R | Database +> => + Effect.gen(function* () { + if (!isAgentIdempotencyKey(input.idempotencyKey)) { + return yield* badRequest( + input.requestId, + "A valid Idempotency-Key header is required", + ); + } + const database = yield* Database; + const requestHash = hash( + JSON.stringify({ + tokenId: input.principal.tokenId, + operation: input.operation, + request: input.request, + }), + ); + const acquired = yield* database.use((db) => + db.transaction(async (tx) => { + const idempotency = await acquireIdempotency(tx, { + userId: input.principal.id, + operation: input.operation, + key: input.idempotencyKey, + requestHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + if (idempotency.state === "replay") { + return { + state: "replay" as const, + response: input.decodeReplay(idempotency.record.response), + }; + } + return idempotency; + }), + ); + if (acquired.state === "conflict") { + return yield* idempotencyConflict(input.requestId); + } + if (acquired.state === "unavailable" || acquired.state === "pending") { + return yield* temporarilyUnavailable(input.requestId); + } + if (acquired.state === "replay") return acquired.response; + const providerIdempotencyKey = hash( + `${input.principal.id}\0${input.operation}\0${input.idempotencyKey}`, + ); + const response = yield* input + .execute(providerIdempotencyKey) + .pipe( + Effect.tapErrorCause(() => + database.use((db) => + db + .delete(Db.agentApiIdempotency) + .where( + and( + eq(Db.agentApiIdempotency.id, acquired.record.id), + eq(Db.agentApiIdempotency.state, "pending"), + ), + ), + ), + ), + ); + return yield* database + .use((db) => + db.transaction(async (tx) => { + const [record] = await tx + .select() + .from(Db.agentApiIdempotency) + .where(eq(Db.agentApiIdempotency.id, acquired.record.id)) + .limit(1) + .for("update"); + if (!record || record.requestHash !== requestHash) { + throw new Error("Could not complete external agent mutation"); + } + if (record.state === "complete") { + return input.decodeReplay(record.response); + } + await completeIdempotency(tx, record.id, response); + return response; + }), + ) + .pipe( + Effect.retry({ + times: 3, + schedule: Schedule.exponential("25 millis"), + }), + ); + }); + +export const createAgentFeedback = Effect.fn("Agent.createFeedback")( + function* (input: { + videoId: Video.VideoId; + principal: Agent.AgentPrincipal["Type"]; + type: "text" | "emoji"; + content: string; + timestampMs: number | null; + parentCommentId: Comment.CommentId | null; + idempotencyKey: string; + requestId: string; + durationMs: number | null; + }) { + const content = input.content.trim(); + const maximumLength = input.type === "emoji" ? 64 : 10_000; + if (content.length === 0 || content.length > maximumLength) { + return yield* badRequest(input.requestId, "Feedback content is invalid"); + } + if ( + input.timestampMs !== null && + (!Number.isFinite(input.timestampMs) || + input.timestampMs < 0 || + (input.durationMs !== null && + input.timestampMs > input.durationMs + 1_000)) + ) { + return yield* badRequest(input.requestId, "timestampMs is invalid"); + } + if (!isAgentIdempotencyKey(input.idempotencyKey)) { + return yield* badRequest( + input.requestId, + "A valid Idempotency-Key header is required", + ); + } + + const database = yield* Database; + const operation = input.parentCommentId + ? "create_reply" + : input.type === "emoji" + ? "create_reaction" + : "create_comment"; + const requestHash = hash( + JSON.stringify({ + videoId: input.videoId, + type: input.type, + content, + timestampMs: input.timestampMs, + parentCommentId: input.parentCommentId, + }), + ); + const result = yield* database.use((db) => + db.transaction(async (tx) => { + const idempotency = await acquireIdempotency(tx, { + userId: input.principal.id, + operation, + key: input.idempotencyKey, + requestHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + if (idempotency.state === "conflict") { + return { state: "conflict" as const }; + } + if (idempotency.state === "unavailable") { + return { state: "unavailable" as const }; + } + if (idempotency.state === "pending") { + return { state: "pending" as const }; + } + if (idempotency.state === "replay") { + return { + state: "replay" as const, + response: Schema.decodeUnknownSync(Agent.AgentFeedbackResponse)( + idempotency.record.response, + ), + }; + } + if (input.parentCommentId) { + const [parent] = await tx + .select({ id: Db.comments.id }) + .from(Db.comments) + .where( + and( + eq(Db.comments.id, input.parentCommentId), + eq(Db.comments.videoId, input.videoId), + eq(Db.comments.type, "text"), + ), + ) + .limit(1); + if (!parent) { + await releaseIdempotency(tx, idempotency.record.id); + return { state: "not_found" as const }; + } + } + const now = new Date(); + const id = Comment.CommentId.make(nanoId()); + await tx.insert(Db.comments).values({ + id, + authorId: input.principal.id, + type: input.type, + content, + videoId: input.videoId, + timestamp: + input.timestampMs === null ? null : input.timestampMs / 1_000, + parentCommentId: input.parentCommentId, + createdAt: now, + updatedAt: now, + }); + const response = { + id, + videoId: input.videoId, + type: input.type, + content, + timestampMs: input.timestampMs, + parentCommentId: input.parentCommentId, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + author: { id: input.principal.id, name: null }, + requestId: input.requestId, + }; + await completeIdempotency(tx, idempotency.record.id, response); + return { state: "created" as const, response }; + }), + ); + + if (result.state === "conflict") { + return yield* idempotencyConflict(input.requestId); + } + if (result.state === "unavailable" || result.state === "pending") { + return yield* temporarilyUnavailable(input.requestId); + } + if (result.state === "not_found") { + return yield* notFound(input.requestId); + } + if (result.state === "created") { + const notificationType = input.parentCommentId + ? "reply" + : input.type === "emoji" + ? "reaction" + : "comment"; + yield* Effect.tryPromise(() => + createNotification({ + type: notificationType, + videoId: input.videoId, + authorId: input.principal.id, + comment: { + id: result.response.id, + content: result.response.content, + }, + parentCommentId: input.parentCommentId ?? undefined, + }), + ).pipe(Effect.catchAll(() => Effect.void)); + } + yield* Effect.try(() => revalidatePath(`/s/${input.videoId}`)).pipe( + Effect.catchAll(() => Effect.void), + ); + return result.response; + }, +); + +export const updateAgentCap = Effect.fn("Agent.updateCap")(function* (input: { + videoId: Video.VideoId; + principal: Agent.AgentPrincipal["Type"]; + title: string | undefined; + public: boolean | undefined; + idempotencyKey: string; + requestId: string; +}) { + const title = input.title?.trim(); + if ( + (title === undefined && input.public === undefined) || + (title !== undefined && (title.length === 0 || title.length > 200)) || + !isAgentIdempotencyKey(input.idempotencyKey) + ) { + return yield* badRequest(input.requestId, "The Cap update is invalid"); + } + const requestHash = hash( + JSON.stringify({ videoId: input.videoId, title, public: input.public }), + ); + const database = yield* Database; + const result = yield* database.use((db) => + db.transaction(async (tx) => { + const idempotency = await acquireIdempotency(tx, { + userId: input.principal.id, + operation: "update_cap", + key: input.idempotencyKey, + requestHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + if (idempotency.state === "conflict") { + return { state: "conflict" as const }; + } + if (idempotency.state === "unavailable") { + return { state: "unavailable" as const }; + } + if (idempotency.state === "pending") { + return { state: "pending" as const }; + } + if (idempotency.state === "replay") { + return { + state: "replay" as const, + response: Schema.decodeUnknownSync(Agent.AgentCapUpdateResponse)( + idempotency.record.response, + ), + }; + } + const [video] = await tx + .select({ + name: Db.videos.name, + public: Db.videos.public, + metadata: Db.videos.metadata, + }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, input.videoId), + eq(Db.videos.ownerId, input.principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) { + await releaseIdempotency(tx, idempotency.record.id); + return { state: "not_found" as const }; + } + const now = new Date(); + const metadata = + video.metadata && + typeof video.metadata === "object" && + !Array.isArray(video.metadata) + ? video.metadata + : {}; + await tx + .update(Db.videos) + .set({ + name: title ?? video.name, + public: input.public ?? video.public, + updatedAt: now, + metadata: + title === undefined + ? metadata + : { ...metadata, titleManuallyEdited: true }, + }) + .where(eq(Db.videos.id, input.videoId)); + const response = { + id: input.videoId, + title: title ?? video.name, + public: input.public ?? video.public, + updatedAt: now.toISOString(), + requestId: input.requestId, + }; + await completeIdempotency(tx, idempotency.record.id, response); + return { state: "updated" as const, response }; + }), + ); + if (result.state === "conflict") { + return yield* idempotencyConflict(input.requestId); + } + if (result.state === "unavailable" || result.state === "pending") { + return yield* temporarilyUnavailable(input.requestId); + } + if (result.state === "not_found") return yield* notFound(input.requestId); + yield* Effect.try(() => { + revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/shared-caps"); + revalidatePath(`/s/${input.videoId}`); + }).pipe(Effect.catchAll(() => Effect.void)); + return result.response; +}); From 66d965bd743a36a1f9afc3601ab96dd22ef9b786 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 11/58] feat(web): add agent management library --- apps/web/lib/agent-management.ts | 357 +++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 apps/web/lib/agent-management.ts diff --git a/apps/web/lib/agent-management.ts b/apps/web/lib/agent-management.ts new file mode 100644 index 00000000000..4fac2b212a1 --- /dev/null +++ b/apps/web/lib/agent-management.ts @@ -0,0 +1,357 @@ +import type { Agent } from "@cap/web-domain"; + +type ActionInput = { + allowed: boolean; + reason?: (typeof Agent.AgentActionReason)["Type"] | null; + requiredScopes?: (typeof Agent.AgentScope)["Type"][]; + confirmation?: (typeof Agent.AgentActionCapability)["Type"]["confirmation"]; + sideEffect?: (typeof Agent.AgentActionCapability)["Type"]["sideEffect"]; + idempotencyRequired?: boolean; + asynchronous?: boolean; +}; + +export const agentAction = ({ + allowed, + reason = allowed ? null : "ROLE_REQUIRED", + requiredScopes = [], + confirmation = "none", + sideEffect = "read", + idempotencyRequired = sideEffect !== "read", + asynchronous = false, +}: ActionInput): (typeof Agent.AgentActionCapability)["Type"] => ({ + allowed, + reason, + requiredScopes, + confirmation, + sideEffect, + idempotencyRequired, + asynchronous, +}); + +export const canUseScope = ( + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, + scope: (typeof Agent.AgentScope)["Type"], +) => scopes.has(scope); + +export const agentViewerSettings = ( + settings: { + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + defaultPlaybackSpeed?: number; + } | null, +): (typeof Agent.AgentViewerSettings)["Type"] => ({ + disableSummary: settings?.disableSummary ?? null, + disableCaptions: settings?.disableCaptions ?? null, + disableChapters: settings?.disableChapters ?? null, + disableReactions: settings?.disableReactions ?? null, + disableTranscript: settings?.disableTranscript ?? null, + disableComments: settings?.disableComments ?? null, + defaultPlaybackSpeed: settings?.defaultPlaybackSpeed ?? null, +}); + +export const agentOrganizationSettings = ( + settings: { + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + hideShareableLinkCapLogo?: boolean; + shareableLinkUseOrganizationIcon?: boolean; + aiGenerationLanguage?: (typeof Agent.AgentAiGenerationLanguage)["Type"]; + defaultPlaybackSpeed?: number; + } | null, +): (typeof Agent.AgentOrganizationSettings)["Type"] => ({ + disableSummary: settings?.disableSummary ?? null, + disableCaptions: settings?.disableCaptions ?? null, + disableChapters: settings?.disableChapters ?? null, + disableReactions: settings?.disableReactions ?? null, + disableTranscript: settings?.disableTranscript ?? null, + disableComments: settings?.disableComments ?? null, + hideShareableLinkCapLogo: settings?.hideShareableLinkCapLogo ?? null, + shareableLinkUseOrganizationIcon: + settings?.shareableLinkUseOrganizationIcon ?? null, + aiGenerationLanguage: settings?.aiGenerationLanguage ?? null, + defaultPlaybackSpeed: settings?.defaultPlaybackSpeed ?? null, +}); + +const scopedAction = ( + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, + scope: (typeof Agent.AgentScope)["Type"], + input: Omit = {}, +) => + agentAction({ + ...input, + allowed: scopes.has(scope), + reason: scopes.has(scope) ? null : "SCOPE_REQUIRED", + requiredScopes: [scope], + }); + +export const profileCapabilities = ( + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +) => ({ + read: scopedAction(scopes, "profile:read"), + update: scopedAction(scopes, "profile:write", { + confirmation: "user", + sideEffect: "write", + }), + updateImage: scopedAction(scopes, "profile:write", { + confirmation: "user", + sideEffect: "write", + }), + createOrganization: scopedAction(scopes, "organizations:manage", { + confirmation: "user", + sideEffect: "write", + }), + signOutAllDevices: scopedAction(scopes, "profile:write", { + confirmation: "user", + sideEffect: "destructive", + }), + openReferrals: scopedAction(scopes, "profile:read", { + confirmation: "browser", + sideEffect: "external", + }), +}); + +export const organizationCapabilities = ( + role: "owner" | "admin" | "member", + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +) => { + const manager = role === "owner" || role === "admin"; + const owner = role === "owner"; + const scopedRoleAction = ( + scope: (typeof Agent.AgentScope)["Type"], + roleAllowed: boolean, + input: Omit = {}, + ) => { + const scopeAllowed = scopes.has(scope); + return agentAction({ + ...input, + allowed: scopeAllowed && roleAllowed, + reason: !scopeAllowed + ? "SCOPE_REQUIRED" + : roleAllowed + ? null + : "ROLE_REQUIRED", + requiredScopes: [scope], + }); + }; + return { + read: scopedRoleAction("organizations:read", true), + update: scopedRoleAction("organizations:manage", manager, { + confirmation: "user", + sideEffect: "write", + }), + manageMembers: scopedRoleAction("organizations:members", manager, { + confirmation: "user", + sideEffect: "external", + }), + manageSeats: scopedRoleAction("organizations:members", manager, { + confirmation: "user", + sideEffect: "write", + }), + manageIntegrations: scopedRoleAction("integrations:write", manager, { + confirmation: "user", + sideEffect: "external", + }), + configureS3: scopedRoleAction("integrations:write", manager, { + confirmation: "secure_input", + sideEffect: "external", + }), + connectGoogleDrive: scopedRoleAction("integrations:write", manager, { + confirmation: "browser", + sideEffect: "external", + }), + selectStorageProvider: scopedRoleAction("integrations:write", manager, { + confirmation: "user", + sideEffect: "write", + }), + manageBranding: scopedRoleAction("organizations:manage", manager, { + confirmation: "user", + sideEffect: "write", + }), + manageDomain: scopedRoleAction("organizations:manage", owner, { + confirmation: "user", + sideEffect: "external", + asynchronous: true, + }), + manageBilling: scopedRoleAction("billing:write", owner, { + confirmation: "browser", + sideEffect: "paid", + }), + delete: scopedRoleAction("organizations:manage", owner, { + confirmation: "browser", + sideEffect: "destructive", + asynchronous: true, + }), + }; +}; + +export const libraryCapabilities = ( + canManage: boolean, + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +) => { + const scopeAllowed = scopes.has("library:write"); + const write = (destructive = false) => + agentAction({ + allowed: scopeAllowed && canManage, + reason: !scopeAllowed + ? "SCOPE_REQUIRED" + : canManage + ? null + : "ROLE_REQUIRED", + requiredScopes: ["library:write"], + confirmation: "user", + sideEffect: destructive ? "destructive" : "write", + }); + return { + read: scopedAction(scopes, "library:read"), + update: write(), + manageMembers: write(), + delete: write(true), + }; +}; + +export const integrationCapabilities = ( + canManage: boolean, + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +) => { + const read = scopedAction(scopes, "integrations:read"); + const scopeAllowed = scopes.has("integrations:write"); + const manage = agentAction({ + allowed: scopeAllowed && canManage, + reason: !scopeAllowed + ? "SCOPE_REQUIRED" + : canManage + ? null + : "ROLE_REQUIRED", + requiredScopes: ["integrations:write"], + confirmation: "user", + sideEffect: "external", + }); + return { + read, + update: manage, + configureS3: { ...manage, confirmation: "secure_input" as const }, + connectGoogleDrive: { ...manage, confirmation: "browser" as const }, + selectProvider: { ...manage, sideEffect: "write" as const }, + disconnect: { ...manage, sideEffect: "destructive" as const }, + }; +}; + +export const developerCapabilities = ( + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +) => ({ + read: scopedAction(scopes, "developer:read"), + update: scopedAction(scopes, "developer:write", { + confirmation: "user", + sideEffect: "write", + }), + rotateSecrets: scopedAction(scopes, "developer:secrets", { + confirmation: "user", + sideEffect: "destructive", + }), + purchaseCredits: scopedAction(scopes, "billing:write", { + confirmation: "browser", + sideEffect: "paid", + }), + deleteVideos: scopedAction(scopes, "developer:write", { + confirmation: "user", + sideEffect: "destructive", + }), +}); + +export const normalizeOrganization = ( + row: { + id: (typeof Agent.AgentOrganization)["Type"]["id"]; + name: string; + ownerId: (typeof Agent.AgentOrganization)["Type"]["ownerId"]; + role: string; + hasProSeat: boolean; + allowedEmailDomain: string | null; + customDomain: string | null; + domainVerifiedAt: Date | null; + settings: Parameters[0]; + icon: string | null; + shareableLinkIcon: string | null; + ownerSubscriptionStatus: string | null; + ownerThirdPartySubscriptionId: string | null; + createdAt: Date; + updatedAt: Date; + }, + scopes: ReadonlySet<(typeof Agent.AgentScope)["Type"]>, +): (typeof Agent.AgentOrganization)["Type"] => { + const role = + row.role === "owner" || row.role === "admin" ? row.role : "member"; + return { + id: row.id, + name: row.name, + ownerId: row.ownerId, + role, + hasProSeat: row.hasProSeat, + allowedEmailDomain: row.allowedEmailDomain, + customDomain: row.customDomain, + domainVerifiedAt: row.domainVerifiedAt?.toISOString() ?? null, + icon: row.icon, + shareableLinkIcon: row.shareableLinkIcon, + settings: agentOrganizationSettings(row.settings), + billing: { + status: row.ownerSubscriptionStatus, + plan: + row.ownerThirdPartySubscriptionId !== null || + ["active", "trialing", "complete", "paid"].includes( + row.ownerSubscriptionStatus ?? "", + ) + ? "pro" + : "free", + }, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: organizationCapabilities(role, scopes), + }; +}; + +export const encodeNotificationCursor = (cursor: { + createdAt: Date; + id: string; +}) => + Buffer.from( + JSON.stringify({ + createdAt: cursor.createdAt.toISOString(), + id: cursor.id, + }), + "utf8", + ).toString("base64url"); + +export const decodeNotificationCursor = (value: string | undefined) => { + if (!value) return null; + if (value.length > 1_024) return undefined; + try { + const parsed: unknown = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const { createdAt, id } = parsed as Record; + if ( + typeof createdAt !== "string" || + typeof id !== "string" || + !/^[A-Za-z0-9_-]{5,128}$/.test(id) + ) { + return undefined; + } + const date = new Date(createdAt); + if (Number.isNaN(date.valueOf()) || date.toISOString() !== createdAt) { + return undefined; + } + return { createdAt: date, id }; + } catch { + return undefined; + } +}; From 5cf1a207af9a4c15c20fec0528cac9d5add5d30e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 12/58] feat(web): add agent server runtime wiring --- apps/web/lib/agent-server.ts | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/web/lib/agent-server.ts diff --git a/apps/web/lib/agent-server.ts b/apps/web/lib/agent-server.ts new file mode 100644 index 00000000000..d1904c34e92 --- /dev/null +++ b/apps/web/lib/agent-server.ts @@ -0,0 +1,50 @@ +import "server-only"; + +import { AgentHttpAuthMiddlewareLive, AgentManagement } from "@cap/web-backend"; +import type { Agent } from "@cap/web-domain"; +import { + type HttpApi, + HttpApiBuilder, + HttpMiddleware, + HttpServer, +} from "@effect/platform"; +import { Layer } from "effect"; +import { allowedOrigins } from "@/utils/cors"; +import { Dependencies } from "./server"; +import { layerTracer } from "./tracing"; + +const cors = HttpApiBuilder.middlewareCors({ + allowedOrigins, + credentials: false, + allowedMethods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "Idempotency-Key", + "X-Cap-Access-Grant", + "X-Cap-Confirmation", + "sentry-trace", + "baggage", + ], +}); + +export const agentApiToHandler = ( + api: Layer.Layer< + HttpApi.Api, + never, + | Layer.Layer.Success + | Agent.AgentHttpAuthMiddleware + | AgentManagement + >, +) => + api.pipe( + HttpMiddleware.withSpanNameGenerator((req) => `${req.method} ${req.url}`), + Layer.provideMerge(AgentHttpAuthMiddlewareLive), + Layer.provideMerge(AgentManagement.Default), + Layer.merge(HttpServer.layerContext), + Layer.provide(cors), + Layer.provide(layerTracer), + Layer.provideMerge(Dependencies), + HttpApiBuilder.toWebHandler, + (v) => (req: Request) => v.handler(req), + ); From 8c6ef58e47512f3495f56de76f6a54e3e658ca22 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 13/58] feat(web): add agent API cleanup utilities --- apps/web/lib/agent-api-cleanup.ts | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/lib/agent-api-cleanup.ts diff --git a/apps/web/lib/agent-api-cleanup.ts b/apps/web/lib/agent-api-cleanup.ts new file mode 100644 index 00000000000..37f705d5a7f --- /dev/null +++ b/apps/web/lib/agent-api-cleanup.ts @@ -0,0 +1,75 @@ +import "server-only"; + +import { db } from "@cap/database"; +import { + agentApiAuthorizationCodes, + agentApiIdempotency, + agentApiKeys, +} from "@cap/database/schema"; +import { lt } from "drizzle-orm"; + +const affectedRows = (result: unknown) => + Array.isArray(result) + ? ((result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0) + : ((result as { affectedRows?: number }).affectedRows ?? 0); + +const deleteInBatches = async ( + deleteBatch: () => Promise, + batchSize: number, + maxBatches: number, +) => { + let deleted = 0; + for (let batch = 0; batch < maxBatches; batch += 1) { + const count = affectedRows(await deleteBatch()); + deleted += count; + if (count < batchSize) break; + } + return deleted; +}; + +export const cleanupExpiredAgentApiRecords = async (input?: { + now?: Date; + batchSize?: number; + maxBatches?: number; + keyRetentionMs?: number; +}) => { + const now = input?.now ?? new Date(); + const batchSize = Math.min(Math.max(input?.batchSize ?? 1_000, 1), 5_000); + const maxBatches = Math.min(Math.max(input?.maxBatches ?? 10, 1), 20); + const keyRetentionMs = Math.max( + input?.keyRetentionMs ?? 30 * 24 * 60 * 60 * 1_000, + 0, + ); + const expiredKeyCutoff = new Date(now.getTime() - keyRetentionMs); + const database = db(); + + const authorizationCodes = await deleteInBatches( + () => + database + .delete(agentApiAuthorizationCodes) + .where(lt(agentApiAuthorizationCodes.expiresAt, now)) + .limit(batchSize), + batchSize, + maxBatches, + ); + const idempotencyRecords = await deleteInBatches( + () => + database + .delete(agentApiIdempotency) + .where(lt(agentApiIdempotency.expiresAt, now)) + .limit(batchSize), + batchSize, + maxBatches, + ); + const accessTokens = await deleteInBatches( + () => + database + .delete(agentApiKeys) + .where(lt(agentApiKeys.expiresAt, expiredKeyCutoff)) + .limit(batchSize), + batchSize, + maxBatches, + ); + + return { authorizationCodes, idempotencyRecords, accessTokens }; +}; From 1e05af427cc50937a817548f13f4845cbd7d02c2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 14/58] feat(web): add agent API rate limit identifiers --- apps/web/lib/rate-limit.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index 168a800f168..7cf6ca16ab4 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -57,6 +57,10 @@ export async function isRateLimited( * corresponding protection to take effect (see `isRateLimited`). */ export const RATE_LIMIT_IDS = { + AGENT_TOKEN_EXCHANGE: "rl_agent_token_exchange", + AGENT_AUTHORIZATION: "rl_agent_authorization", + AGENT_UNLOCK: "rl_agent_unlock", + AGENT_LOOM_IMPORT: "rl_loom_import_per_user", /** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */ AUTH_OTP_VERIFY: "rl_auth_otp_verify", /** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */ From 2d1490d33d9583501e681ea435425aef5899a153 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:28 +0100 Subject: [PATCH 15/58] feat(web): add agent cap operation workflow --- apps/web/workflows/agent-cap-operation.ts | 558 ++++++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 apps/web/workflows/agent-cap-operation.ts diff --git a/apps/web/workflows/agent-cap-operation.ts b/apps/web/workflows/agent-cap-operation.ts new file mode 100644 index 00000000000..ab3678a212c --- /dev/null +++ b/apps/web/workflows/agent-cap-operation.ts @@ -0,0 +1,558 @@ +import { db } from "@cap/database"; +import * as Db from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { Organisations, Storage } from "@cap/web-backend"; +import { + CurrentUser, + Folder, + Organisation, + S3Bucket, + Storage as StorageDomain, + User, + Video, +} from "@cap/web-domain"; +import { and, eq, ne } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { FatalError } from "workflow"; +import { + addDomain, + checkDomainStatus, + getDomainResponse, +} from "@/actions/organization/domain-utils"; +import { runPromise } from "@/lib/server"; + +type CapSnapshot = { + id: string; + ownerId: string; + orgId: string; + name: string; + bucket: string | null; + storageIntegrationId: string | null; + duration: number | null; + width: number | null; + height: number | null; + fps: number | null; + metadata: Record | null; + public: boolean; + settings: Record | null; + transcriptionStatus: string | null; + source: { + type: + | "MediaConvert" + | "local" + | "desktopMP4" + | "desktopSegments" + | "webMP4"; + }; + folderId: string | null; + isScreenshot: boolean; + skipProcessing: boolean; +}; + +type OperationPayload = { + snapshot: CapSnapshot; + destinationId: string | null; +}; + +type OrganizationOperationPayload = { + organizationId: string; + domain?: string; +}; + +const storageVideo = (snapshot: CapSnapshot) => + Video.Video.make({ + id: Video.VideoId.make(snapshot.id), + ownerId: User.UserId.make(snapshot.ownerId), + orgId: Organisation.OrganisationId.make(snapshot.orgId), + name: snapshot.name, + public: snapshot.public, + source: snapshot.source, + metadata: Option.fromNullable(snapshot.metadata), + bucketId: Option.fromNullable(snapshot.bucket).pipe( + Option.map(S3Bucket.S3BucketId.make), + ), + storageIntegrationId: Option.fromNullable( + snapshot.storageIntegrationId, + ).pipe(Option.map(StorageDomain.StorageIntegrationId.make)), + folderId: Option.none(), + transcriptionStatus: Option.none(), + width: Option.fromNullable(snapshot.width), + height: Option.fromNullable(snapshot.height), + duration: Option.fromNullable(snapshot.duration), + createdAt: new Date(0), + updatedAt: new Date(0), + }); + +async function claimOperation(operationId: string) { + "use step"; + + return db().transaction(async (tx) => { + const [operation] = await tx + .select() + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if (!operation) throw new FatalError("Agent operation not found"); + if (operation.state !== "queued") return null; + await tx + .update(Db.agentApiOperations) + .set({ state: "running", updatedAt: new Date() }) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.agentApiOperations.state, "queued"), + ), + ); + return { + kind: operation.kind, + payload: operation.payload, + }; + }); +} + +async function listOperationObjects(snapshot: CapSnapshot) { + const [bucket] = await Storage.getAccessForVideo(storageVideo(snapshot)).pipe( + runPromise, + ); + const prefix = `${snapshot.ownerId}/${snapshot.id}/`; + const keys: string[] = []; + let continuationToken: string | undefined; + do { + const page = await bucket + .listObjects({ prefix, maxKeys: 1_000, continuationToken }) + .pipe(runPromise); + for (const object of page.Contents ?? []) { + if (object.Key) keys.push(object.Key); + } + continuationToken = page.IsTruncated + ? page.NextContinuationToken + : undefined; + } while (continuationToken); + return { bucket, keys, prefix }; +} + +async function copyCapObjects(payload: OperationPayload) { + "use step"; + + if (!payload.destinationId) + throw new FatalError("Duplicate destination missing"); + const { bucket, keys, prefix } = await listOperationObjects(payload.snapshot); + const destinationPrefix = `${payload.snapshot.ownerId}/${payload.destinationId}/`; + await Effect.forEach( + keys, + (key) => + bucket.copyObject( + `${bucket.bucketName}/${key}`, + key.replace(prefix, destinationPrefix), + ), + { concurrency: 4 }, + ).pipe(runPromise); +} + +async function createDuplicate(operationId: string, payload: OperationPayload) { + "use step"; + + if (!payload.destinationId) + throw new FatalError("Duplicate destination missing"); + const snapshot = payload.snapshot; + const destinationId = Video.VideoId.make(payload.destinationId); + await db().transaction(async (tx) => { + const [existing] = await tx + .select({ ownerId: Db.videos.ownerId }) + .from(Db.videos) + .where(eq(Db.videos.id, destinationId)) + .limit(1) + .for("update"); + if (existing && existing.ownerId !== snapshot.ownerId) { + throw new FatalError("Duplicate destination conflicts with another Cap"); + } + if (!existing) { + await tx.insert(Db.videos).values({ + id: destinationId, + ownerId: User.UserId.make(snapshot.ownerId), + orgId: Organisation.OrganisationId.make(snapshot.orgId), + name: snapshot.name, + bucket: snapshot.bucket + ? S3Bucket.S3BucketId.make(snapshot.bucket) + : null, + storageIntegrationId: snapshot.storageIntegrationId + ? StorageDomain.StorageIntegrationId.make( + snapshot.storageIntegrationId, + ) + : null, + duration: snapshot.duration, + width: snapshot.width, + height: snapshot.height, + fps: snapshot.fps, + metadata: snapshot.metadata, + public: snapshot.public, + settings: snapshot.settings, + transcriptionStatus: snapshot.transcriptionStatus as + | "PROCESSING" + | "COMPLETE" + | "ERROR" + | "SKIPPED" + | "NO_AUDIO" + | null, + source: snapshot.source, + folderId: snapshot.folderId + ? Folder.FolderId.make(snapshot.folderId) + : null, + isScreenshot: snapshot.isScreenshot, + skipProcessing: snapshot.skipProcessing, + }); + } + const now = new Date(); + await tx + .update(Db.agentApiOperations) + .set({ + state: "succeeded", + result: { id: destinationId }, + updatedAt: now, + completedAt: now, + }) + .where(eq(Db.agentApiOperations.id, operationId)); + }); +} + +async function deleteCapObjects(payload: OperationPayload) { + "use step"; + + const { bucket, keys } = await listOperationObjects(payload.snapshot); + for (let index = 0; index < keys.length; index += 1_000) { + await bucket + .deleteObjects( + keys.slice(index, index + 1_000).map((key) => ({ Key: key })), + ) + .pipe(runPromise); + } +} + +async function deleteCapDatabase( + operationId: string, + payload: OperationPayload, +) { + "use step"; + + const videoId = Video.VideoId.make(payload.snapshot.id); + await db().transaction(async (tx) => { + await tx.delete(Db.comments).where(eq(Db.comments.videoId, videoId)); + await tx + .delete(Db.notifications) + .where(eq(Db.notifications.videoId, videoId)); + await tx + .delete(Db.videoUploads) + .where(eq(Db.videoUploads.videoId, videoId)); + await tx.delete(Db.importedVideos).where(eq(Db.importedVideos.id, videoId)); + await tx + .delete(Db.sharedVideos) + .where(eq(Db.sharedVideos.videoId, videoId)); + await tx.delete(Db.spaceVideos).where(eq(Db.spaceVideos.videoId, videoId)); + await tx.delete(Db.videoEdits).where(eq(Db.videoEdits.videoId, videoId)); + await tx.delete(Db.videos).where(eq(Db.videos.id, videoId)); + const now = new Date(); + await tx + .update(Db.agentApiOperations) + .set({ + state: "succeeded", + result: { deleted: true }, + updatedAt: now, + completedAt: now, + }) + .where(eq(Db.agentApiOperations.id, operationId)); + }); +} + +async function deleteOrganization( + operationId: string, + payload: OrganizationOperationPayload, +) { + "use step"; + + const organizationId = Organisation.OrganisationId.make( + payload.organizationId, + ); + const [user] = await db() + .select({ + id: Db.users.id, + email: Db.users.email, + activeOrganizationId: Db.users.activeOrganizationId, + image: Db.users.image, + }) + .from(Db.agentApiOperations) + .innerJoin(Db.users, eq(Db.agentApiOperations.userId, Db.users.id)) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1); + if (!user) throw new FatalError("Agent operation owner not found"); + await Organisations.pipe( + Effect.flatMap((organizations) => organizations.softDelete(organizationId)), + Effect.provideService(CurrentUser, { + id: user.id, + email: user.email, + activeOrganizationId: Organisation.OrganisationId.make( + user.activeOrganizationId ?? "", + ), + iconUrlOrKey: Option.fromNullable(user.image), + }), + ).pipe(runPromise); + const [remainingOrganization] = await db() + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where(eq(Db.organizations.id, organizationId)) + .limit(1); + if (remainingOrganization) { + throw new FatalError("Organization deletion did not complete"); + } + const now = new Date(); + await db() + .update(Db.agentApiOperations) + .set({ + state: "succeeded", + result: { deleted: true }, + updatedAt: now, + completedAt: now, + }) + .where(eq(Db.agentApiOperations.id, operationId)); +} + +async function getOrganizationDomainContext( + operationId: string, + payload: OrganizationOperationPayload, +) { + "use step"; + const organizationId = Organisation.OrganisationId.make( + payload.organizationId, + ); + + const [row] = await db() + .select({ + userId: Db.agentApiOperations.userId, + ownerId: Db.organizations.ownerId, + memberRole: Db.organizationMembers.role, + customDomain: Db.organizations.customDomain, + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.agentApiOperations) + .innerJoin( + Db.organizations, + eq(Db.agentApiOperations.resourceId, Db.organizations.id), + ) + .innerJoin(Db.users, eq(Db.agentApiOperations.userId, Db.users.id)) + .leftJoin( + Db.organizationMembers, + and( + eq(Db.organizationMembers.organizationId, Db.organizations.id), + eq(Db.organizationMembers.userId, Db.agentApiOperations.userId), + ), + ) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.organizations.id, organizationId), + ), + ) + .limit(1); + if (!row) throw new FatalError("Organization domain target not found"); + if ( + row.userId !== row.ownerId && + row.memberRole !== "owner" && + row.memberRole !== "admin" + ) { + throw new FatalError("Organization domain access was revoked"); + } + return row; +} + +async function addOrganizationDomain(domain: string) { + "use step"; + + const response = await addDomain(domain); + if (response.error) { + const existing = await getDomainResponse(domain); + if (existing?.error) { + throw new FatalError(response.error.message ?? "Could not add domain"); + } + } + return checkDomainStatus(domain); +} + +async function removeOrganizationDomainFromVercel(domain: string) { + "use step"; + + const response = await fetch( + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain.toLowerCase()}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + }, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(`Domain removal returned HTTP ${response.status}`); + } +} + +async function verifyOrganizationDomainStatus(domain: string) { + "use step"; + + return checkDomainStatus(domain); +} + +async function finishOrganizationDomain( + operationId: string, + payload: OrganizationOperationPayload, + result: { domain: string | null; verified: boolean }, +) { + "use step"; + const organizationId = Organisation.OrganisationId.make( + payload.organizationId, + ); + + await db().transaction(async (tx) => { + const [organization] = await tx + .select({ customDomain: Db.organizations.customDomain }) + .from(Db.organizations) + .where(eq(Db.organizations.id, organizationId)) + .limit(1) + .for("update"); + if (!organization) throw new FatalError("Organization not found"); + if (result.domain) { + const [conflict] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.customDomain, result.domain), + ne(Db.organizations.id, organizationId), + ), + ) + .limit(1); + if (conflict) throw new FatalError("Domain is already in use"); + } else if ( + payload.domain && + organization.customDomain && + organization.customDomain !== payload.domain + ) { + throw new FatalError("Organization domain changed during removal"); + } + const now = new Date(); + await tx + .update(Db.organizations) + .set({ + customDomain: result.domain, + domainVerified: result.verified ? now : null, + updatedAt: now, + }) + .where(eq(Db.organizations.id, organizationId)); + await tx + .update(Db.agentApiOperations) + .set({ + state: "succeeded", + result, + updatedAt: now, + completedAt: now, + }) + .where(eq(Db.agentApiOperations.id, operationId)); + }); +} + +async function updateOrganizationDomain( + operationId: string, + kind: + | "set_organization_domain" + | "remove_organization_domain" + | "verify_organization_domain", + payload: OrganizationOperationPayload, +) { + const context = await getOrganizationDomainContext(operationId, payload); + if (kind === "set_organization_domain") { + if (!payload.domain) throw new FatalError("Organization domain is missing"); + if (!userIsPro(context)) { + throw new FatalError("Cap Pro is required for custom domains"); + } + const status = await addOrganizationDomain(payload.domain); + await finishOrganizationDomain(operationId, payload, { + domain: payload.domain, + verified: status.verified, + }); + return; + } + const domain = context.customDomain; + if (!domain) { + await finishOrganizationDomain(operationId, payload, { + domain: null, + verified: false, + }); + return; + } + if (kind === "remove_organization_domain") { + await removeOrganizationDomainFromVercel(domain); + await finishOrganizationDomain( + operationId, + { ...payload, domain }, + { domain: null, verified: false }, + ); + return; + } + const status = await verifyOrganizationDomainStatus(domain); + await finishOrganizationDomain(operationId, payload, { + domain, + verified: status.verified, + }); +} + +async function failOperation(operationId: string, error: unknown) { + "use step"; + + const now = new Date(); + const message = + error instanceof Error ? error.message : "Agent operation failed"; + await db() + .update(Db.agentApiOperations) + .set({ + state: "failed", + errorCode: "OPERATION_FAILED", + errorMessage: message.slice(0, 2_000), + updatedAt: now, + completedAt: now, + }) + .where(eq(Db.agentApiOperations.id, operationId)); +} + +export async function agentCapOperationWorkflow(input: { + operationId: string; +}) { + "use workflow"; + + try { + const operation = await claimOperation(input.operationId); + if (!operation) return; + if (operation.kind === "duplicate_cap") { + const payload = operation.payload as OperationPayload; + await copyCapObjects(payload); + await createDuplicate(input.operationId, payload); + return; + } + if (operation.kind === "delete_cap") { + const payload = operation.payload as OperationPayload; + await deleteCapObjects(payload); + await deleteCapDatabase(input.operationId, payload); + return; + } + if (operation.kind === "import_loom") { + throw new FatalError("Loom imports use the Loom import workflow"); + } + const payload = operation.payload as OrganizationOperationPayload; + if (operation.kind === "delete_organization") { + await deleteOrganization(input.operationId, payload); + return; + } + await updateOrganizationDomain(input.operationId, operation.kind, payload); + } catch (error) { + await failOperation(input.operationId, error); + throw error; + } +} From f8530100e9f3fc157b5bce07ac98778a98f45eeb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 16/58] feat(web): track agent operations in Loom import workflow --- apps/web/actions/loom.ts | 1 - apps/web/workflows/import-loom-video.ts | 75 ++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 2924c2d859d..fc9bd47972a 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -441,7 +441,6 @@ async function importLoomVideoForOwner({ userId: ownerId, rawFileKey, bucketId: Option.getOrNull(writable.bucketId), - loomDownloadUrl: downloadUrl, loomVideoId, }, ]); diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index 2576c7aece6..0757d1a0050 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; -import { videos, videoUploads } from "@cap/database/schema"; +import { agentApiOperations, videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { Storage } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -14,8 +14,9 @@ interface ImportLoomPayload { userId: string; rawFileKey: string; bucketId: string | null; - loomDownloadUrl: string; loomVideoId: string; + loomDownloadUrl?: string; + agentOperationId?: string; } const MINIMUM_VIDEO_SIZE = 1024; @@ -143,17 +144,86 @@ interface LoomProcessingInput { inputExtension?: string; } +async function claimAgentImport(operationId: string | undefined) { + "use step"; + + if (!operationId) return true; + return db().transaction(async (tx) => { + const [operation] = await tx + .select({ + kind: agentApiOperations.kind, + state: agentApiOperations.state, + }) + .from(agentApiOperations) + .where(eq(agentApiOperations.id, operationId)) + .limit(1) + .for("update"); + if (!operation || operation.kind !== "import_loom") { + throw new FatalError("Agent Loom import operation not found"); + } + if (operation.state !== "queued") return false; + await tx + .update(agentApiOperations) + .set({ state: "running", updatedAt: new Date() }) + .where(eq(agentApiOperations.id, operationId)); + return true; + }); +} + +async function completeAgentImport( + operationId: string | undefined, + videoId: string, +) { + "use step"; + + if (!operationId) return; + const now = new Date(); + await db() + .update(agentApiOperations) + .set({ + state: "succeeded", + result: { videoId }, + updatedAt: now, + completedAt: now, + }) + .where(eq(agentApiOperations.id, operationId)); +} + +async function failAgentImport( + operationId: string | undefined, + message: string, +) { + "use step"; + + if (!operationId) return; + const now = new Date(); + await db() + .update(agentApiOperations) + .set({ + state: "failed", + errorCode: "LOOM_IMPORT_FAILED", + errorMessage: message.slice(0, 2_000), + updatedAt: now, + completedAt: now, + }) + .where(eq(agentApiOperations.id, operationId)); +} + export async function importLoomVideoWorkflow( payload: ImportLoomPayload, ): Promise { "use workflow"; + if (!(await claimAgentImport(payload.agentOperationId))) { + return { success: true, message: "Loom import is already running" }; + } try { const processingInput = await downloadLoomToS3(payload); const result = await processVideoOnMediaServer(payload, processingInput); await saveMetadataAndComplete(payload.videoId, result.metadata); + await completeAgentImport(payload.agentOperationId, payload.videoId); return { success: true, @@ -163,6 +233,7 @@ export async function importLoomVideoWorkflow( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); await setProcessingError(payload.videoId, errorMessage); + await failAgentImport(payload.agentOperationId, errorMessage); throw new FatalError(errorMessage); } } From 544b257705a1f1cd2e61d435025a77718f03b092 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 17/58] fix(web): update video processing recovery tests for Loom import --- .../unit/video-processing-recovery.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/web/__tests__/unit/video-processing-recovery.test.ts b/apps/web/__tests__/unit/video-processing-recovery.test.ts index bd7612dcdd5..60b3387e0c9 100644 --- a/apps/web/__tests__/unit/video-processing-recovery.test.ts +++ b/apps/web/__tests__/unit/video-processing-recovery.test.ts @@ -9,6 +9,11 @@ vi.mock("@cap/database", () => ({ })); vi.mock("@cap/database/schema", () => ({ + importedVideos: { + id: "importedVideos.id", + source: "importedVideos.source", + sourceId: "importedVideos.sourceId", + }, videos: { id: "videos.id", ownerId: "videos.ownerId", @@ -19,6 +24,7 @@ vi.mock("@cap/database/schema", () => ({ videoId: "videoUploads.videoId", phase: "videoUploads.phase", processingError: "videoUploads.processingError", + processingMessage: "videoUploads.processingMessage", rawFileKey: "videoUploads.rawFileKey", updatedAt: "videoUploads.updatedAt", }, @@ -29,7 +35,9 @@ vi.mock("drizzle-orm", () => ({ asc: vi.fn((value: unknown) => value), eq: vi.fn((left: unknown, right: unknown) => ({ left, right })), isNotNull: vi.fn((value: unknown) => value), + isNull: vi.fn((value: unknown) => value), like: vi.fn((left: unknown, right: unknown) => ({ left, right })), + lte: vi.fn((left: unknown, right: unknown) => ({ left, right })), sql: vi.fn(), })); @@ -45,6 +53,10 @@ vi.mock("@/workflows/process-video", () => ({ processVideoWorkflow: vi.fn(), })); +vi.mock("@/workflows/import-loom-video", () => ({ + importLoomVideoWorkflow: vi.fn(), +})); + type Candidate = { videoId: string; userId: string; @@ -98,6 +110,7 @@ beforeEach(() => { describe("recoverFailedVideoProcessing", () => { it("atomically claims and restarts an affected upload", async () => { mockDb + .mockReturnValueOnce(makeSelectChain([])) .mockReturnValueOnce(makeSelectChain([candidate])) .mockReturnValueOnce(makeUpdateChain(1)); const { recoverFailedVideoProcessing } = await import( @@ -112,6 +125,7 @@ describe("recoverFailedVideoProcessing", () => { it("does not start a duplicate workflow when another run owns the claim", async () => { mockDb + .mockReturnValueOnce(makeSelectChain([])) .mockReturnValueOnce(makeSelectChain([candidate])) .mockReturnValueOnce(makeUpdateChain(0)); const { recoverFailedVideoProcessing } = await import( @@ -126,6 +140,7 @@ describe("recoverFailedVideoProcessing", () => { it("keeps a transient start failure eligible for the next recovery run", async () => { mockDb + .mockReturnValueOnce(makeSelectChain([])) .mockReturnValueOnce(makeSelectChain([candidate])) .mockReturnValueOnce(makeUpdateChain(1)); mockStart.mockRejectedValueOnce(new Error("temporary failure")); From f06574d3388bd2f204b932bea0d8a6a4080989ab Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 18/58] feat(web): add agent API v1 route handler --- apps/web/app/api/v1/[...route]/route.ts | 8590 +++++++++++++++++++++++ 1 file changed, 8590 insertions(+) create mode 100644 apps/web/app/api/v1/[...route]/route.ts diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts new file mode 100644 index 00000000000..21e169e4396 --- /dev/null +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -0,0 +1,8590 @@ +import { createHash, createHmac } from "node:crypto"; +import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; +import { + decrypt, + encrypt, + hashPassword, + verifyPassword, +} from "@cap/database/crypto"; +import { sendEmail } from "@cap/database/emails/config"; +import { OrganizationInvite } from "@cap/database/emails/organization-invite"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { buildEnv, serverEnv } from "@cap/env"; +import { + STRIPE_DEVELOPER_CREDITS_PRODUCT_ID, + STRIPE_PLAN_IDS, + stripe, + userIsPro, +} from "@cap/utils"; +import { + AgentManagement, + collectPasswordHashes, + Database, + type GoogleDriveIntegrationConfig, + getGoogleDriveAccessToken, + getGoogleDriveAuthUrl, + getGoogleDriveFolderLocation, + getGoogleDriveUserEmail, + ImageUploads, + resolveEffectiveVideoRules, + Storage, + Videos, +} from "@cap/web-backend"; +import { + Agent, + Comment, + CurrentUser, + Folder, + type ImageUpload, + Organisation, + S3Bucket, + Space, + Storage as StorageDomain, + Video, +} from "@cap/web-domain"; +import { + HttpApiBuilder, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { + and, + desc, + eq, + gt, + inArray, + isNull, + like, + lt, + ne, + or, + sql, +} from "drizzle-orm"; +import { union } from "drizzle-orm/mysql-core"; +import { Effect, Layer, Option, Schema } from "effect"; +import { revalidatePath } from "next/cache"; +import { start } from "workflow/api"; +import { downloadLoomVideo } from "@/actions/loom"; +import { getOrgAnalyticsData } from "@/app/(org)/dashboard/analytics/data"; +import { + createAgentAccessGrant, + readAgentAccessGrant, +} from "@/lib/agent-access-grant"; +import { + agentCapabilities, + agentStatus, + agentTranscriptRevision, + decodeAgentCursor, + encodeAgentCursor, + normalizeAgentMetadata, + parseAgentDate, + parseAgentLimit, + parseAgentVtt, + renderAgentVtt, + transcriptTextFromCues, +} from "@/lib/agent-api"; +import { + agentAction, + agentViewerSettings, + decodeNotificationCursor, + developerCapabilities, + encodeNotificationCursor, + integrationCapabilities, + libraryCapabilities, + normalizeOrganization, + organizationCapabilities, + profileCapabilities, +} from "@/lib/agent-management"; +import { agentApiToHandler } from "@/lib/agent-server"; +import { + exchangeAgentAuthorizationCode, + getAgentAuthStatus, + revokeAgentAccessToken, +} from "@/lib/agent-token"; +import { + createAgentFeedback, + isAgentWriteAccessEnabled, + runAgentExternalMutation, + runAgentMutation, + updateAgentCap, +} from "@/lib/agent-write"; +import { hashKey } from "@/lib/developer-key-hash"; +import { startAiGeneration } from "@/lib/generate-ai"; +import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; +import { + canChangeOrganizationMemberRole, + canRemoveOrganizationMember, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; +import { normalizePlaybackSpeed } from "@/lib/playback-speed"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { transcribeVideo } from "@/lib/transcribe"; +import { startVideoProcessingWorkflow } from "@/lib/video-processing"; +import { isAiGenerationEnabled } from "@/utils/flags"; +import { + calculateProSeats, + hasActiveDirectSubscription, + selectProSeatProvider, +} from "@/utils/organization"; +import { agentCapOperationWorkflow } from "@/workflows/agent-cap-operation"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; + +export const dynamic = "force-dynamic"; + +type CapRow = { + id: Video.VideoId; + ownerId: (typeof Db.users.$inferSelect)["id"]; + ownerName: string | null; + orgId: (typeof Db.organizations.$inferSelect)["id"]; + name: string; + public: boolean; + hasPassword: boolean; + duration: number | null; + folderId: (typeof Db.folders.$inferSelect)["id"] | null; + createdAt: Date; + updatedAt: Date; + width: number | null; + height: number | null; + fps: number | null; + source: (typeof Db.videos.$inferSelect)["source"]; + metadata: unknown; + transcriptionStatus: (typeof Db.videos.$inferSelect)["transcriptionStatus"]; + videoSettings: (typeof Db.videos.$inferSelect)["settings"]; + organizationSettings: (typeof Db.organizations.$inferSelect)["settings"]; + commentCount: number; + reactionCount: number; + uploadPhase: (typeof Db.videoUploads.$inferSelect)["phase"] | null; + uploadError: string | null; +}; + +type SpaceRuleRow = { + videoId: Video.VideoId; + id: string; + name: string; + settings: (typeof Db.spaces.$inferSelect)["settings"]; + hasPassword: boolean; +}; + +type TranscriptData = { + vtt: string; + text: string; + cues: (typeof Agent.AgentTranscriptCue)["Type"][]; +}; + +type StatusRow = Pick< + CapRow, + | "id" + | "updatedAt" + | "transcriptionStatus" + | "metadata" + | "uploadPhase" + | "uploadError" +>; + +const makeRequestId = () => crypto.randomUUID(); + +const deterministicAgentId = (namespace: string, ...parts: string[]) => + createHash("sha256") + .update([namespace, ...parts].join("\0"), "utf8") + .digest("base64url") + .slice(0, 15); + +const affectedRows = (result: unknown) => + Array.isArray(result) + ? ((result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0) + : ((result as { affectedRows?: number }).affectedRows ?? 0); + +const defaultProOrganizationSettings = { + disableSummary: false, + disableChapters: false, + disableTranscript: false, + hideShareableLinkCapLogo: false, + shareableLinkUseOrganizationIcon: false, + aiGenerationLanguage: "auto" as const, +}; + +const DeveloperKeyOperationResult = Schema.Struct({ + appId: Schema.String, + publicKeyId: Schema.String, + secretKeyId: Schema.String, +}); + +const readDeveloperCredentials = Effect.fn("Agent.readDeveloperCredentials")( + function* ( + principal: Agent.AgentPrincipal["Type"], + result: (typeof DeveloperKeyOperationResult)["Type"], + requestId: string, + ) { + const database = yield* Database; + const rows = yield* database.use((db) => + db + .select({ + id: Db.developerApiKeys.id, + encryptedKey: Db.developerApiKeys.encryptedKey, + }) + .from(Db.developerApiKeys) + .innerJoin( + Db.developerApps, + eq(Db.developerApiKeys.appId, Db.developerApps.id), + ) + .where( + and( + eq(Db.developerApps.id, result.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + inArray(Db.developerApiKeys.id, [ + result.publicKeyId, + result.secretKeyId, + ]), + ), + ), + ); + const publicKey = rows.find((row) => row.id === result.publicKeyId); + const secretKey = rows.find((row) => row.id === result.secretKeyId); + if (!publicKey || !secretKey) { + return yield* temporarilyUnavailable( + requestId, + "Developer credentials are temporarily unavailable", + ); + } + const [publicKeyRaw, secretKeyRaw] = yield* Effect.tryPromise(() => + Promise.all([ + decrypt(publicKey.encryptedKey), + decrypt(secretKey.encryptedKey), + ]), + ).pipe( + Effect.mapError(() => + temporarilyUnavailable( + requestId, + "Developer credentials are temporarily unavailable", + ), + ), + ); + return { + appId: result.appId, + publicKey: publicKeyRaw, + secretKey: secretKeyRaw, + requestId, + }; + }, +); + +const commonError = (requestId: string, message: string) => ({ + message, + retryable: false, + retryAfterMs: null, + requestId, +}); + +const badRequest = (requestId: string, message: string) => + new Agent.AgentBadRequestError({ + ...commonError(requestId, message), + code: "INVALID_REQUEST", + }); + +const forbidden = (requestId: string, message = "Access is not allowed") => + new Agent.AgentForbiddenError({ + ...commonError(requestId, message), + code: "FORBIDDEN", + }); + +const passwordRequired = (requestId: string) => + new Agent.AgentForbiddenError({ + ...commonError( + requestId, + "This Cap must be unlocked before it can be read", + ), + code: "PASSWORD_REQUIRED", + }); + +const contentDisabled = (requestId: string, content: string) => + new Agent.AgentForbiddenError({ + ...commonError(requestId, `${content} is disabled for this Cap`), + code: "CONTENT_DISABLED", + }); + +const notFound = (requestId: string) => + new Agent.AgentNotFoundError({ + ...commonError(requestId, "Cap not found"), + code: "NOT_FOUND", + }); + +const notReady = (requestId: string, message: string) => + new Agent.AgentNotReadyError({ + message, + code: "NOT_READY", + retryable: true, + retryAfterMs: 2_000, + requestId, + }); + +const temporarilyUnavailable = (requestId: string, message: string) => + new Agent.AgentTemporaryUnavailableError({ + message, + code: "TEMPORARY_UNAVAILABLE", + retryable: true, + retryAfterMs: null, + requestId, + }); + +const rateLimited = (requestId: string) => + new Agent.AgentRateLimitedError({ + message: "Too many requests. Try again later", + code: "RATE_LIMITED", + retryable: true, + retryAfterMs: 60_000, + requestId, + }); + +const approvalRequired = (requestId: string, message: string) => + new Agent.AgentApprovalRequiredError({ + ...commonError(requestId, message), + code: "APPROVAL_REQUIRED", + approvalUrl: null, + }); + +const withMappedErrors = ( + effect: Effect.Effect, + requestId: string, +) => + effect.pipe( + Effect.catchTags({ + DatabaseError: () => + Effect.fail( + temporarilyUnavailable(requestId, "The Cap library is unavailable"), + ), + NoSuchElementException: () => Effect.fail(notFound(requestId)), + PolicyDenied: () => Effect.fail(forbidden(requestId)), + S3Error: () => + Effect.fail( + temporarilyUnavailable(requestId, "Cap storage is unavailable"), + ), + StorageError: () => + Effect.fail( + temporarilyUnavailable(requestId, "Cap storage is unavailable"), + ), + UnknownException: () => + Effect.fail( + temporarilyUnavailable( + requestId, + "The request could not be completed", + ), + ), + VerifyVideoPasswordError: () => Effect.fail(passwordRequired(requestId)), + VideoNotFoundError: () => Effect.fail(notFound(requestId)), + }), + Effect.tapErrorCause(Effect.logError), + ); + +const toCurrentUser = ( + principal: Agent.AgentPrincipal["Type"], +): CurrentUser["Type"] => ({ + id: principal.id, + email: principal.email, + activeOrganizationId: principal.activeOrganizationId, + iconUrlOrKey: Option.none(), +}); + +const hasScope = ( + principal: Agent.AgentPrincipal["Type"], + scope: Agent.AgentScope, +) => principal.scopes.has(scope); + +const requireScope = ( + principal: Agent.AgentPrincipal["Type"], + scope: Agent.AgentScope, + requestId: string, +) => + principal.scopes.has(scope) + ? Effect.void + : Effect.fail(forbidden(requestId, `The ${scope} permission is required`)); + +const pickViewerSettings = ( + settings: + | (typeof Db.videos.$inferSelect)["settings"] + | (typeof Db.organizations.$inferSelect)["settings"] + | (typeof Db.spaces.$inferSelect)["settings"], +) => ({ + disableSummary: settings?.disableSummary, + disableCaptions: settings?.disableCaptions, + disableChapters: settings?.disableChapters, + disableReactions: settings?.disableReactions, + disableTranscript: settings?.disableTranscript, + disableComments: settings?.disableComments, +}); + +const getMetadataChapterValues = (metadata: unknown) => { + const value = normalizeAgentMetadata(metadata).chapters; + if (!Array.isArray(value)) return []; + return value.flatMap((chapter) => { + if (!chapter || typeof chapter !== "object" || Array.isArray(chapter)) { + return []; + } + const { title, start } = chapter as Record; + return typeof title === "string" && + typeof start === "number" && + Number.isFinite(start) + ? [{ title, startMs: Math.round(start * 1000) }] + : []; + }); +}; + +const getMetadataString = (metadata: unknown, key: "aiTitle" | "summary") => { + const value = normalizeAgentMetadata(metadata)[key]; + return typeof value === "string" && value.length > 0 ? value : null; +}; + +const getStatus = (row: StatusRow) => { + const metadata = normalizeAgentMetadata(row.metadata); + return agentStatus({ + id: row.id, + updatedAt: row.updatedAt, + transcriptionStatus: row.transcriptionStatus, + aiGenerationStatus: metadata.aiGenerationStatus, + uploadPhase: row.uploadPhase, + uploadError: row.uploadError, + }); +}; + +const getCapRows = Effect.fn("Agent.getCapRows")(function* ( + videoId?: Video.VideoId, +) { + const database = yield* Database; + return yield* database.use((db) => { + const query = db + .select({ + id: Db.videos.id, + ownerId: Db.videos.ownerId, + ownerName: Db.users.name, + orgId: Db.videos.orgId, + name: Db.videos.name, + public: Db.videos.public, + hasPassword: sql`${Db.videos.password} IS NOT NULL`.mapWith( + Boolean, + ), + duration: Db.videos.duration, + folderId: Db.videos.folderId, + createdAt: Db.videos.createdAt, + updatedAt: Db.videos.updatedAt, + width: Db.videos.width, + height: Db.videos.height, + fps: Db.videos.fps, + source: Db.videos.source, + metadata: Db.videos.metadata, + transcriptionStatus: Db.videos.transcriptionStatus, + videoSettings: Db.videos.settings, + organizationSettings: Db.organizations.settings, + commentCount: sql`( + SELECT COUNT(*) FROM ${Db.comments} + WHERE ${Db.comments.videoId} = ${Db.videos.id} + AND ${Db.comments.type} = 'text' + )`.mapWith(Number), + reactionCount: sql`( + SELECT COUNT(*) FROM ${Db.comments} + WHERE ${Db.comments.videoId} = ${Db.videos.id} + AND ${Db.comments.type} = 'emoji' + )`.mapWith(Number), + uploadPhase: Db.videoUploads.phase, + uploadError: Db.videoUploads.processingError, + }) + .from(Db.videos) + .innerJoin(Db.organizations, eq(Db.videos.orgId, Db.organizations.id)) + .leftJoin(Db.users, eq(Db.videos.ownerId, Db.users.id)) + .leftJoin(Db.videoUploads, eq(Db.videos.id, Db.videoUploads.videoId)); + + return videoId ? query.where(eq(Db.videos.id, videoId)).limit(1) : query; + }); +}); + +const getStatusRow = Effect.fn("Agent.getStatusRow")(function* ( + videoId: Video.VideoId, +) { + const database = yield* Database; + const [row] = yield* database.use((db) => + db + .select({ + id: Db.videos.id, + updatedAt: Db.videos.updatedAt, + transcriptionStatus: Db.videos.transcriptionStatus, + metadata: Db.videos.metadata, + uploadPhase: Db.videoUploads.phase, + uploadError: Db.videoUploads.processingError, + }) + .from(Db.videos) + .leftJoin(Db.videoUploads, eq(Db.videos.id, Db.videoUploads.videoId)) + .where(eq(Db.videos.id, videoId)) + .limit(1), + ); + if (!row) return yield* Effect.fail(new Video.NotFoundError()); + return row; +}); + +const getSpaceRules = Effect.fn("Agent.getSpaceRules")(function* ( + videoIds: ReadonlyArray, +) { + if (videoIds.length === 0) return [] as SpaceRuleRow[]; + const database = yield* Database; + return yield* database.use((db) => + db + .select({ + videoId: Db.spaceVideos.videoId, + id: Db.spaces.id, + name: Db.spaces.name, + settings: Db.spaces.settings, + hasPassword: sql`${Db.spaces.password} IS NOT NULL`.mapWith( + Boolean, + ), + }) + .from(Db.spaceVideos) + .innerJoin(Db.spaces, eq(Db.spaceVideos.spaceId, Db.spaces.id)) + .where(inArray(Db.spaceVideos.videoId, videoIds)), + ); +}); + +const rulesForRow = (row: CapRow, spaceRows: ReadonlyArray) => + resolveEffectiveVideoRules({ + videoSettings: pickViewerSettings(row.videoSettings), + organizationSettings: pickViewerSettings(row.organizationSettings), + spaces: spaceRows + .filter((space) => space.videoId === row.id) + .map((space) => ({ + id: space.id, + name: space.name, + settings: pickViewerSettings(space.settings), + hasPassword: space.hasPassword, + })), + }); + +const toCapSummary = ( + row: CapRow, + rules: ReturnType, + principal: Agent.AgentPrincipal["Type"], + locked: boolean, +): (typeof Agent.AgentCapSummary)["Type"] => { + const metadata = normalizeAgentMetadata(row.metadata); + const isOwner = row.ownerId === principal.id; + return { + id: row.id, + shareUrl: `${serverEnv().WEB_URL}/s/${row.id}`, + title: row.name, + aiTitle: getMetadataString(metadata, "aiTitle"), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + durationMs: row.duration === null ? null : Math.round(row.duration * 1000), + owner: { id: row.ownerId, name: row.ownerName }, + organizationId: row.orgId, + folderId: row.folderId, + access: isOwner ? "owned" : "shared", + sharing: { + public: row.public, + protected: row.hasPassword || rules.hasInheritedPassword, + }, + counts: { + comments: Number(row.commentCount), + reactions: Number(row.reactionCount), + }, + status: getStatus(row), + capabilities: agentCapabilities({ + isOwner, + hasReadScope: hasScope(principal, "caps:read"), + hasCommentScope: hasScope(principal, "caps:comment"), + hasWriteScope: hasScope(principal, "caps:write"), + hasProcessScope: hasScope(principal, "caps:process"), + hasDeleteScope: hasScope(principal, "caps:delete"), + passwordRequired: locked, + transcriptStatus: row.transcriptionStatus, + hasSummary: + typeof metadata.summary === "string" && metadata.summary.length > 0, + hasChapters: + Array.isArray(metadata.chapters) && metadata.chapters.length > 0, + settings: rules.settings, + }), + }; +}; + +const getViewableVideo = Effect.fn("Agent.getViewableVideo")(function* ( + videoId: Video.VideoId, + verifiedPasswordHashes?: ReadonlyArray, +) { + const principal = yield* Agent.AgentPrincipal; + const request = yield* HttpServerRequest.HttpServerRequest; + const grant = + verifiedPasswordHashes === undefined + ? yield* Effect.promise(() => + readAgentAccessGrant( + request.headers["x-cap-access-grant"], + videoId, + principal.id, + ), + ) + : null; + const passwords = + verifiedPasswordHashes ?? (grant ? [grant.passwordHash] : []); + const videos = yield* Videos; + const maybeVideo = yield* videos + .getByIdForViewing(videoId) + .pipe( + Effect.provideService(CurrentUser, toCurrentUser(principal)), + Effect.provideService(Video.VideoPasswordAttachment, { passwords }), + ); + if (Option.isNone(maybeVideo)) + return yield* Effect.fail(new Video.NotFoundError()); + return maybeVideo.value[0]; +}); + +const getViewableCap = Effect.fn("Agent.getViewableCap")(function* ( + videoId: Video.VideoId, + verifiedPasswordHashes?: ReadonlyArray, +) { + const principal = yield* Agent.AgentPrincipal; + const video = yield* getViewableVideo(videoId, verifiedPasswordHashes); + const [row] = yield* getCapRows(videoId); + if (!row) return yield* Effect.fail(new Video.NotFoundError()); + const spaces = yield* getSpaceRules([videoId]); + const rules = rulesForRow(row, spaces); + return { + video, + row, + rules, + cap: toCapSummary(row, rules, principal, false), + }; +}); + +const listCaps = Effect.fn("Agent.listCaps")(function* ( + params: (typeof Agent.AgentCapsListParams)["Type"], + requestId: string, +) { + const principal = yield* Agent.AgentPrincipal; + const database = yield* Database; + const limit = parseAgentLimit(params.limit); + if (limit === null) + return yield* badRequest(requestId, "limit must be positive"); + const cursor = decodeAgentCursor(params.cursor); + if (cursor === undefined) + return yield* badRequest(requestId, "cursor is invalid"); + const updatedAfter = parseAgentDate(params.updatedAfter); + if (updatedAfter === undefined) { + return yield* badRequest(requestId, "updatedAfter is invalid"); + } + const search = params.search?.trim(); + if (search && search.length > 200) { + return yield* badRequest(requestId, "search is too long"); + } + + const rows = yield* database.use((db) => { + const organizationId = params.organizationId + ? Organisation.OrganisationId.make(params.organizationId) + : null; + const folderId = params.folderId + ? Folder.FolderId.make(params.folderId) + : null; + const ownedCaps = db + .select({ videoId: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.ownerId, principal.id), + organizationId ? eq(Db.videos.orgId, organizationId) : undefined, + folderId ? eq(Db.videos.folderId, folderId) : undefined, + ), + ); + const organizationCaps = db + .select({ videoId: Db.sharedVideos.videoId }) + .from(Db.organizationMembers) + .innerJoin( + Db.sharedVideos, + eq( + Db.organizationMembers.organizationId, + Db.sharedVideos.organizationId, + ), + ) + .where( + and( + eq(Db.organizationMembers.userId, principal.id), + organizationId + ? eq(Db.sharedVideos.organizationId, organizationId) + : undefined, + folderId ? eq(Db.sharedVideos.folderId, folderId) : undefined, + ), + ); + const spaceCaps = db + .select({ videoId: Db.spaceVideos.videoId }) + .from(Db.spaceMembers) + .innerJoin( + Db.spaceVideos, + eq(Db.spaceMembers.spaceId, Db.spaceVideos.spaceId), + ) + .innerJoin(Db.spaces, eq(Db.spaceMembers.spaceId, Db.spaces.id)) + .where( + and( + eq(Db.spaceMembers.userId, principal.id), + organizationId + ? eq(Db.spaces.organizationId, organizationId) + : undefined, + folderId ? eq(Db.spaceVideos.folderId, folderId) : undefined, + ), + ); + const accessibleCaps = + params.scope === "owned" + ? ownedCaps.as("accessible_caps") + : params.scope === "shared" + ? union(organizationCaps, spaceCaps).as("accessible_caps") + : union(ownedCaps, organizationCaps, spaceCaps).as("accessible_caps"); + const cursorFilter = cursor + ? or( + lt(Db.videos.updatedAt, new Date(cursor.updatedAt)), + and( + eq(Db.videos.updatedAt, new Date(cursor.updatedAt)), + lt(Db.videos.id, Video.VideoId.make(cursor.id)), + ), + ) + : undefined; + const filters = [ + params.scope === "shared" + ? ne(Db.videos.ownerId, principal.id) + : undefined, + isNull(Db.organizations.tombstoneAt), + search ? like(Db.videos.name, `%${search}%`) : undefined, + updatedAfter ? gt(Db.videos.updatedAt, updatedAfter) : undefined, + cursorFilter, + ]; + + return db + .select({ + id: Db.videos.id, + ownerId: Db.videos.ownerId, + ownerName: Db.users.name, + orgId: Db.videos.orgId, + name: Db.videos.name, + public: Db.videos.public, + hasPassword: sql`${Db.videos.password} IS NOT NULL`.mapWith( + Boolean, + ), + duration: Db.videos.duration, + folderId: Db.videos.folderId, + createdAt: Db.videos.createdAt, + updatedAt: Db.videos.updatedAt, + width: Db.videos.width, + height: Db.videos.height, + fps: Db.videos.fps, + source: Db.videos.source, + metadata: Db.videos.metadata, + transcriptionStatus: Db.videos.transcriptionStatus, + videoSettings: Db.videos.settings, + organizationSettings: Db.organizations.settings, + commentCount: sql`( + SELECT COUNT(*) FROM ${Db.comments} + WHERE ${Db.comments.videoId} = ${Db.videos.id} + AND ${Db.comments.type} = 'text' + )`.mapWith(Number), + reactionCount: sql`( + SELECT COUNT(*) FROM ${Db.comments} + WHERE ${Db.comments.videoId} = ${Db.videos.id} + AND ${Db.comments.type} = 'emoji' + )`.mapWith(Number), + uploadPhase: Db.videoUploads.phase, + uploadError: Db.videoUploads.processingError, + }) + .from(Db.videos) + .innerJoin(accessibleCaps, eq(Db.videos.id, accessibleCaps.videoId)) + .innerJoin(Db.organizations, eq(Db.videos.orgId, Db.organizations.id)) + .leftJoin(Db.users, eq(Db.videos.ownerId, Db.users.id)) + .leftJoin(Db.videoUploads, eq(Db.videos.id, Db.videoUploads.videoId)) + .where(and(...filters)) + .orderBy(desc(Db.videos.updatedAt), desc(Db.videos.id)) + .limit(limit + 1); + }); + + const pageRows = rows.slice(0, limit); + const spaces = yield* getSpaceRules(pageRows.map((row) => row.id)); + const caps = pageRows.map((row) => { + const rules = rulesForRow(row, spaces); + const isOwner = row.ownerId === principal.id; + return toCapSummary( + row, + rules, + principal, + !isOwner && (row.hasPassword || rules.hasInheritedPassword), + ); + }); + const last = pageRows.at(-1); + return { + caps, + nextCursor: + rows.length > limit && last + ? encodeAgentCursor({ + updatedAt: last.updatedAt.toISOString(), + id: last.id, + }) + : null, + requestId, + }; +}); + +const readTranscript = Effect.fn("Agent.readTranscript")(function* ( + video: Video.Video, + requestId: string, +) { + const storage = yield* Storage; + const [bucket] = yield* storage.getAccessForVideo(video); + const object = yield* bucket.getObject( + `${video.ownerId}/${video.id}/transcription.vtt`, + ); + if (Option.isNone(object)) { + return yield* notReady( + requestId, + "Transcript content is not available yet", + ); + } + const cues = parseAgentVtt(object.value); + return { + vtt: object.value, + cues, + text: transcriptTextFromCues(cues), + revision: agentTranscriptRevision(object.value), + }; +}); + +const getFeedback = Effect.fn("Agent.getFeedback")(function* ( + videoId: Video.VideoId, +) { + const database = yield* Database; + return yield* database.use((db) => + db + .select({ + id: Db.comments.id, + videoId: Db.comments.videoId, + type: Db.comments.type, + content: Db.comments.content, + timestamp: Db.comments.timestamp, + parentCommentId: Db.comments.parentCommentId, + createdAt: Db.comments.createdAt, + updatedAt: Db.comments.updatedAt, + authorId: Db.comments.authorId, + authorName: Db.users.name, + }) + .from(Db.comments) + .leftJoin(Db.users, eq(Db.comments.authorId, Db.users.id)) + .where(eq(Db.comments.videoId, videoId)) + .orderBy(Db.comments.createdAt, Db.comments.id), + ); +}); + +const getContext = Effect.fn("Agent.getContext")(function* ( + videoId: Video.VideoId, + requestId: string, +) { + const { video, row, rules, cap } = yield* getViewableCap(videoId); + const videos = yield* Videos; + const principal = yield* Agent.AgentPrincipal; + const metadata = normalizeAgentMetadata(row.metadata); + const summary = getMetadataString(metadata, "summary"); + const chapters = getMetadataChapterValues(metadata); + const canReadTranscript = cap.capabilities.transcript.allowed; + const canReadComments = cap.capabilities.comments.allowed; + const canReadReactions = cap.capabilities.reactions.allowed; + + const [transcript, feedback, views] = yield* Effect.all( + [ + canReadTranscript + ? readTranscript(video, requestId).pipe( + Effect.map(Option.some), + Effect.catchTag("AgentNotReadyError", () => + Effect.succeed(Option.none()), + ), + ) + : Effect.succeed(Option.none()), + canReadComments || canReadReactions + ? getFeedback(videoId) + : Effect.succeed([]), + videos.getAnalytics(videoId).pipe( + Effect.provideService(CurrentUser, toCurrentUser(principal)), + Effect.map((result) => ({ + status: "available" as const, + aggregate: result.count, + reason: null, + })), + Effect.catchAll(() => + Effect.succeed({ + status: "unavailable" as const, + aggregate: null, + reason: "ANALYTICS_UNAVAILABLE", + }), + ), + ), + ], + { concurrency: 3 }, + ); + + const comments = feedback + .filter((item) => item.type === "text") + .map((item) => ({ + id: Comment.CommentId.make(item.id), + videoId: item.videoId, + content: item.content, + timestampMs: + item.timestamp === null ? null : Math.round(item.timestamp * 1000), + parentCommentId: item.parentCommentId, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + author: { id: item.authorId, name: item.authorName }, + })); + const reactions = feedback + .filter((item) => item.type === "emoji") + .map((item) => ({ + id: Comment.CommentId.make(item.id), + videoId: item.videoId, + content: item.content, + timestampMs: + item.timestamp === null ? null : Math.round(item.timestamp * 1000), + createdAt: item.createdAt.toISOString(), + author: { id: item.authorId, name: item.authorName }, + })); + + return { + cap, + title: { + current: row.name, + ai: getMetadataString(metadata, "aiTitle"), + manuallyEdited: metadata.titleManuallyEdited === true, + }, + summary: rules.settings.disableSummary + ? { + status: "unavailable" as const, + reason: "CONTENT_DISABLED", + value: null, + } + : summary + ? { status: "available" as const, reason: null, value: summary } + : { status: "not_ready" as const, reason: "NOT_READY", value: null }, + chapters: rules.settings.disableChapters + ? { + status: "unavailable" as const, + reason: "CONTENT_DISABLED", + value: null, + } + : chapters.length > 0 + ? { status: "available" as const, reason: null, value: chapters } + : { status: "not_ready" as const, reason: "NOT_READY", value: null }, + transcript: rules.settings.disableTranscript + ? { + status: "unavailable" as const, + reason: "CONTENT_DISABLED", + text: null, + cues: null, + } + : Option.isSome(transcript) + ? { + status: "available" as const, + reason: null, + text: transcript.value.text, + cues: transcript.value.cues, + } + : { + status: "not_ready" as const, + reason: "NOT_READY", + text: null, + cues: null, + }, + comments: canReadComments + ? { status: "available" as const, reason: null, value: comments } + : { + status: "unavailable" as const, + reason: "CONTENT_DISABLED", + value: null, + }, + reactions: canReadReactions + ? { status: "available" as const, reason: null, value: reactions } + : { + status: "unavailable" as const, + reason: "CONTENT_DISABLED", + value: null, + }, + views, + metadata: { + source: row.source.type, + width: row.width, + height: row.height, + fps: row.fps, + }, + requestId, + }; +}); + +const unlockCap = Effect.fn("Agent.unlockCap")(function* ( + videoId: Video.VideoId, + requestId: string, +) { + const principal = yield* Agent.AgentPrincipal; + const request = yield* HttpServerRequest.HttpServerRequest; + const contentType = request.headers["content-type"]?.toLowerCase() ?? ""; + if (!contentType.startsWith("text/plain")) { + return yield* badRequest(requestId, "Password input must be text/plain"); + } + const contentLength = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(contentLength) && contentLength > 1_024) { + return yield* badRequest(requestId, "Password input is too large"); + } + const password = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(1_024)), + Effect.catchTag("RequestError", () => + Effect.fail(badRequest(requestId, "Password input is invalid")), + ), + ); + if (password.length === 0 || Buffer.byteLength(password, "utf8") > 512) { + return yield* badRequest(requestId, "Password input is invalid"); + } + + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + headers.set(name, value); + } + if ( + yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.AGENT_UNLOCK, { + key: `agent-unlock:${principal.id}`, + headers, + }), + ) + ) { + return yield* rateLimited(requestId); + } + const requiresPassword = yield* getViewableVideo(videoId).pipe( + Effect.as(false), + Effect.catchTag("VerifyVideoPasswordError", () => Effect.succeed(true)), + ); + if (!requiresPassword) { + return yield* badRequest(requestId, "This Cap does not require a password"); + } + + const database = yield* Database; + const [videoRows, spacePasswords] = yield* database.use((db) => + Promise.all([ + db + .select({ password: Db.videos.password }) + .from(Db.videos) + .where(eq(Db.videos.id, videoId)) + .limit(1), + db + .select({ password: Db.spaces.password }) + .from(Db.spaceVideos) + .innerJoin(Db.spaces, eq(Db.spaceVideos.spaceId, Db.spaces.id)) + .where(eq(Db.spaceVideos.videoId, videoId)), + ]), + ); + const [videoRow] = videoRows; + if (!videoRow) return yield* notFound(requestId); + const passwordHashes = collectPasswordHashes({ + videoPassword: videoRow.password, + spacePasswords, + }); + if (passwordHashes.length === 0) return yield* notFound(requestId); + const verifiedHash = yield* Effect.tryPromise(async () => { + for (const passwordHash of passwordHashes) { + if (await verifyPassword(passwordHash, password)) return passwordHash; + } + return null; + }); + if (!verifiedHash) return yield* passwordRequired(requestId); + yield* getViewableCap(videoId, [verifiedHash]); + const { grant, expiresAt } = yield* Effect.promise(() => + createAgentAccessGrant(videoId, principal.id, verifiedHash), + ); + return { + accessGrant: grant, + expiresAt: expiresAt.toISOString(), + requestId, + }; +}); + +const requireAgentWrites = (requestId: string) => + isAgentWriteAccessEnabled({ + nodeEnv: process.env.NODE_ENV, + enabled: process.env.CAP_AGENT_API_WRITE_ENABLED, + }) + ? Effect.void + : Effect.fail( + temporarilyUnavailable( + requestId, + "Cap agent mutations are currently disabled", + ), + ); + +const capabilityFailure = ( + requestId: string, + capability: (typeof Agent.AgentCapability)["Type"], +) => { + switch (capability.reason) { + case "CONTENT_DISABLED": + return contentDisabled(requestId, "This action"); + case "PASSWORD_REQUIRED": + return passwordRequired(requestId); + default: + return forbidden(requestId); + } +}; + +const requestIdempotencyKey = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return request.headers["idempotency-key"] ?? ""; +}); + +const decodeMutationResponse = Schema.decodeUnknownSync( + Agent.AgentMutationResponse, +); + +const decodeBrowserActionResponse = Schema.decodeUnknownSync( + Agent.AgentBrowserActionResponse, +); + +const mutationResponse = ( + type: string, + id: string, + action: string, + requestId: string, + revision: Date | null = null, +): (typeof Agent.AgentMutationResponse)["Type"] => ({ + resource: { + type, + id, + revision: revision?.toISOString() ?? null, + }, + action, + requestId, +}); + +const decodeAgentImage = ( + payload: (typeof Agent.AgentImageInput)["Type"], + maximumBytes = 1024 * 1024, +) => { + const contentType = payload.contentType.trim().toLowerCase(); + if (!new Set(["image/jpeg", "image/png"]).has(contentType)) return null; + if ( + payload.fileName.length === 0 || + payload.fileName.length > 255 || + payload.data.length === 0 || + payload.data.length > Math.ceil((maximumBytes * 4) / 3) + 4 + ) { + return null; + } + const data = Buffer.from(payload.data, "base64"); + if ( + data.length === 0 || + data.length > maximumBytes || + data.toString("base64").replace(/=+$/, "") !== + payload.data.replace(/=+$/, "") + ) { + return null; + } + return { + contentType, + fileName: payload.fileName, + data: new Uint8Array(data), + }; +}; + +const ensureAgentStripeCustomer = Effect.fn("Agent.ensureStripeCustomer")( + function* ( + principal: Agent.AgentPrincipal["Type"], + providerIdempotencyKey: string, + requestId: string, + ) { + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + stripeCustomerId: Db.users.stripeCustomerId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account) return yield* notFound(requestId); + if (account.stripeCustomerId) return account.stripeCustomerId; + const customer = yield* Effect.tryPromise(async () => { + const existing = await stripe().customers.list({ + email: principal.email, + limit: 1, + }); + const first = existing.data[0]; + if (first) { + return stripe().customers.update( + first.id, + { + metadata: { ...first.metadata, userId: principal.id }, + }, + { idempotencyKey: `${providerIdempotencyKey}:customer-update` }, + ); + } + return stripe().customers.create( + { + email: principal.email, + metadata: { userId: principal.id }, + }, + { idempotencyKey: `${providerIdempotencyKey}:customer-create` }, + ); + }); + yield* database.use((db) => + db + .update(Db.users) + .set({ stripeCustomerId: customer.id }) + .where(eq(Db.users.id, principal.id)), + ); + return customer.id; + }, +); + +const googleDriveProvider = "googleDrive"; + +const createAgentGoogleDriveState = ( + userId: string, + organizationId: Organisation.OrganisationId, +) => { + const payload = Buffer.from( + JSON.stringify({ + userId, + expiresAt: Date.now() + 10 * 60 * 1000, + scope: "organization", + organizationId, + agent: true, + }), + ).toString("base64url"); + const signature = createHmac("sha256", serverEnv().NEXTAUTH_SECRET) + .update(payload) + .digest("base64url"); + return `${payload}.${signature}`; +}; + +const getAgentOrganizationDrive = Effect.fn("Agent.getOrganizationDrive")( + function* (organizationId: Organisation.OrganisationId) { + const database = yield* Database; + const [drive] = yield* database.use((db) => + db + .select() + .from(Db.storageIntegrations) + .where( + and( + eq(Db.storageIntegrations.organizationId, organizationId), + eq(Db.storageIntegrations.provider, googleDriveProvider), + ), + ) + .orderBy( + desc(Db.storageIntegrations.active), + desc(Db.storageIntegrations.updatedAt), + ) + .limit(1), + ); + return drive ?? null; + }, +); + +const parseAgentDriveConfig = (encryptedConfig: string) => + Effect.tryPromise( + async () => + JSON.parse( + await decrypt(encryptedConfig), + ) as GoogleDriveIntegrationConfig, + ); + +const resolveAgentS3Config = Effect.fn("Agent.resolveS3Config")(function* ( + organizationId: Organisation.OrganisationId, + payload: (typeof Agent.AgentS3ConfigInput)["Type"], + requestId: string, +) { + const provider = payload.provider.trim(); + const endpoint = payload.endpoint.trim(); + const bucketName = payload.bucketName.trim(); + const region = payload.region.trim(); + if ( + provider.length === 0 || + provider.length > 64 || + bucketName.length === 0 || + bucketName.length > 255 || + region.length === 0 || + region.length > 64 + ) { + return yield* badRequest(requestId, "S3 configuration is invalid"); + } + if (endpoint) { + try { + const url = new URL(endpoint); + if (!new Set(["http:", "https:"]).has(url.protocol)) { + return yield* badRequest(requestId, "S3 endpoint is invalid"); + } + } catch { + return yield* badRequest(requestId, "S3 endpoint is invalid"); + } + } + let accessKeyId = payload.accessKeyId.trim(); + let secretAccessKey = payload.secretAccessKey.trim(); + if (!accessKeyId || !secretAccessKey) { + if (accessKeyId || secretAccessKey) { + return yield* badRequest( + requestId, + "Provide both S3 access key ID and secret access key", + ); + } + const database = yield* Database; + const [existing] = yield* database.use((db) => + db + .select({ + accessKeyId: Db.s3Buckets.accessKeyId, + secretAccessKey: Db.s3Buckets.secretAccessKey, + }) + .from(Db.s3Buckets) + .where( + and( + eq(Db.s3Buckets.organizationId, organizationId), + eq(Db.s3Buckets.active, true), + ), + ) + .orderBy(desc(Db.s3Buckets.updatedAt)) + .limit(1), + ); + if (!existing) { + return yield* badRequest( + requestId, + "S3 access key ID and secret access key are required", + ); + } + [accessKeyId, secretAccessKey] = yield* Effect.tryPromise(() => + Promise.all([ + decrypt(existing.accessKeyId), + decrypt(existing.secretAccessKey), + ]), + ); + } + if (accessKeyId.length > 512 || secretAccessKey.length > 2048) { + return yield* badRequest(requestId, "S3 credentials are invalid"); + } + return { + provider, + endpoint, + bucketName, + region, + accessKeyId, + secretAccessKey, + }; +}); + +const testAgentS3Config = Effect.fn("Agent.testS3Config")(function* ( + config: { + provider: string; + endpoint: string; + bucketName: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + }, + requestId: string, +) { + return yield* Effect.tryPromise({ + try: async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5_000); + try { + const client = new S3Client({ + endpoint: config.endpoint || undefined, + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + await client.send( + new HeadBucketCommand({ Bucket: config.bucketName }), + { abortSignal: controller.signal }, + ); + } finally { + clearTimeout(timeout); + } + }, + catch: () => + badRequest( + requestId, + "Cap could not access the S3 bucket with this configuration", + ), + }); +}); + +type ViewerSettingsInput = typeof Agent.AgentViewerSettingsInput.Type; + +const mergeViewerSettings = < + T extends Record | null | undefined, +>( + current: T, + update: ViewerSettingsInput, +) => { + const next: Record = { ...(current ?? {}) }; + for (const [key, value] of Object.entries(update)) { + if (value === null) delete next[key]; + else if (value !== undefined) next[key] = value; + } + return next; +}; + +const hasViewerSettingsUpdate = (value: ViewerSettingsInput) => + Object.values(value).some((setting) => setting !== undefined); + +const mergeSpaceViewerSettings = ( + current: (typeof Db.spaces.$inferSelect)["settings"], + update: ViewerSettingsInput, +): NonNullable<(typeof Db.spaces.$inferInsert)["settings"]> => + mergeViewerSettings(current, { + disableSummary: update.disableSummary, + disableCaptions: update.disableCaptions, + disableChapters: update.disableChapters, + disableReactions: update.disableReactions, + disableTranscript: update.disableTranscript, + disableComments: update.disableComments, + }) as NonNullable<(typeof Db.spaces.$inferInsert)["settings"]>; + +const hasProSpaceSettingEnabled = (settings: ViewerSettingsInput) => + settings.disableSummary === true || + settings.disableChapters === true || + settings.disableTranscript === true; + +const AgentUploadMutationState = Schema.Struct({ + id: Video.VideoId, + organizationId: Organisation.OrganisationId, + rawFileKey: Schema.String, + shareUrl: Schema.String, + requestId: Schema.String, +}); + +const AgentUploadCompletionState = Schema.Struct({ + id: Video.VideoId, + ownerId: Schema.String, + bucketId: Schema.NullOr(Schema.String), + rawFileKey: Schema.String, + requestId: Schema.String, +}); + +const AgentProcessMutationState = Schema.Struct({ + id: Video.VideoId, + ownerId: Schema.String, + target: Schema.Literal("transcript", "ai", "all"), + transcriptionStatus: Schema.NullOr(Schema.String), + aiGenerationStatus: Schema.NullOr(Schema.String), + aiEnabled: Schema.Boolean, + requestId: Schema.String, +}); + +const AgentOperationMutationState = Schema.Struct({ + operationId: Schema.String, + requestId: Schema.String, +}); + +const toAgentOperationResponse = ( + operation: typeof Db.agentApiOperations.$inferSelect, + requestId: string, +): (typeof Agent.AgentOperationResponse)["Type"] => ({ + id: operation.id, + kind: operation.kind, + state: operation.state, + resourceId: operation.resourceId, + resultResourceId: operation.resultResourceId, + result: operation.result ?? null, + error: + operation.errorCode && operation.errorMessage + ? { code: operation.errorCode, message: operation.errorMessage } + : null, + createdAt: operation.createdAt.toISOString(), + updatedAt: operation.updatedAt.toISOString(), + completedAt: operation.completedAt?.toISOString() ?? null, + requestId, +}); + +const getAgentOperation = Effect.fn("Agent.getOperation")(function* ( + operationId: string, + requestId: string, +) { + if (!/^[A-Za-z0-9_-]{5,128}$/.test(operationId)) { + return yield* badRequest(requestId, "Operation ID is invalid"); + } + const principal = yield* Agent.AgentPrincipal; + const database = yield* Database; + const [operation] = yield* database.use((db) => + db + .select() + .from(Db.agentApiOperations) + .where( + and( + eq(Db.agentApiOperations.id, operationId), + eq(Db.agentApiOperations.userId, principal.id), + ), + ) + .limit(1), + ); + if (!operation) return yield* notFound(requestId); + return toAgentOperationResponse(operation, requestId); +}); + +const requireUserConfirmedRequest = Effect.fn( + "Agent.requireUserConfirmedRequest", +)(function* (requestId: string) { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.headers["x-cap-confirmation"] !== "user") { + return yield* approvalRequired( + requestId, + "Explicit user confirmation is required for this operation", + ); + } +}); + +type AgentImageTarget = + | { type: "profile" } + | { + type: "organization"; + organizationId: Organisation.OrganisationId; + image: "icon" | "shareableLinkIcon"; + } + | { type: "folder"; folderId: Folder.FolderId } + | { type: "space"; spaceId: Space.SpaceIdOrOrganisationId }; + +const requireAgentStorageManager = Effect.fn("Agent.requireStorageManager")( + function* ( + principal: Agent.AgentPrincipal["Type"], + organizationId: Organisation.OrganisationId, + scope: "integrations:read" | "integrations:write", + requestId: string, + ) { + yield* requireScope(principal, scope, requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager(principal.id, organizationId); + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account || !userIsPro(account)) { + return yield* forbidden( + requestId, + "Cap Pro is required to manage storage integrations", + ); + } + }, +); + +const updateAgentImage = Effect.fn("Agent.updateImage")(function* (input: { + target: AgentImageTarget; + payload: (typeof Agent.AgentImageInput)["Type"] | null; + requestId: string; +}) { + yield* requireAgentWrites(input.requestId); + yield* requireUserConfirmedRequest(input.requestId); + const principal = yield* Agent.AgentPrincipal; + const image = input.payload ? decodeAgentImage(input.payload) : null; + if (input.payload && !image) { + return yield* badRequest( + input.requestId, + "Image must be a PNG or JPEG no larger than 1 MB", + ); + } + let collectionOrganizationId: Organisation.OrganisationId | null = null; + if (input.target.type === "profile") { + yield* requireScope(principal, "profile:write", input.requestId); + } else if (input.target.type === "organization") { + yield* requireScope(principal, "organizations:manage", input.requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + input.target.organizationId, + ); + } else { + yield* requireScope(principal, "library:write", input.requestId); + const management = yield* AgentManagement; + const access = + input.target.type === "folder" + ? yield* management + .getFolderAccess(principal.id, input.target.folderId) + .pipe( + Effect.map((access) => ({ + canManage: access.canManage, + organizationId: access.folder.organizationId, + })), + ) + : yield* management + .getSpaceAccess(principal.id, input.target.spaceId) + .pipe( + Effect.map((access) => ({ + canManage: access.canManage, + organizationId: access.organizationId, + })), + ); + if (!access.canManage) return yield* forbidden(input.requestId); + collectionOrganizationId = access.organizationId; + const organization = normalizeOrganization( + yield* management.getOrganization(principal.id, collectionOrganizationId), + principal.scopes, + ); + if (organization.billing.plan !== "pro") { + return yield* forbidden( + input.requestId, + "Cap Pro is required for collection branding", + ); + } + } + const targetId = + input.target.type === "profile" + ? principal.id + : input.target.type === "organization" + ? input.target.organizationId + : input.target.type === "folder" + ? input.target.folderId + : input.target.spaceId; + const operation = + input.target.type === "profile" + ? "update_profile_image" + : input.target.type === "organization" + ? input.target.image === "icon" + ? "update_organization_icon" + : "update_shareable_link_icon" + : input.target.type === "folder" + ? "update_folder_logo" + : "update_space_logo"; + return yield* runAgentExternalMutation({ + principal, + operation, + idempotencyKey: yield* requestIdempotencyKey, + request: { + target: input.target, + image: input.payload + ? { + contentType: input.payload.contentType, + fileName: input.payload.fileName, + sha256: createHash("sha256") + .update(input.payload.data) + .digest("hex"), + } + : null, + }, + requestId: input.requestId, + decodeReplay: decodeMutationResponse, + execute: () => + Effect.gen(function* () { + const database = yield* Database; + const imageUploads = yield* ImageUploads; + const target = input.target; + if (target.type === "profile") { + const [account] = yield* database.use((db) => + db + .select({ image: Db.users.image }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account) return yield* notFound(input.requestId); + yield* imageUploads.applyUpdate({ + payload: image ? Option.some(image) : Option.none(), + existing: Option.fromNullable(account.image), + keyPrefix: `users/${principal.id}`, + update: (db, urlOrKey) => + db + .update(Db.users) + .set({ image: urlOrKey }) + .where(eq(Db.users.id, principal.id)), + }); + } else if (target.type === "organization") { + const organizationTarget = target; + const [organization] = yield* database.use((db) => + db + .select({ + icon: Db.organizations.iconUrl, + shareableLinkIcon: Db.organizations.shareableLinkIconUrl, + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.organizations) + .innerJoin(Db.users, eq(Db.organizations.ownerId, Db.users.id)) + .where( + and( + eq(Db.organizations.id, organizationTarget.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1), + ); + if (!organization) return yield* notFound(input.requestId); + if ( + organizationTarget.image === "shareableLinkIcon" && + !userIsPro(organization) + ) { + return yield* forbidden( + input.requestId, + "Cap Pro is required for shareable link branding", + ); + } + const existing = + organizationTarget.image === "icon" + ? organization.icon + : organization.shareableLinkIcon; + const keyPrefix = + organizationTarget.image === "icon" + ? `organizations/${organizationTarget.organizationId}` + : `organizations/${organizationTarget.organizationId}/shareable-links`; + yield* imageUploads.applyUpdate({ + payload: image ? Option.some(image) : Option.none(), + existing: Option.fromNullable(existing), + keyPrefix, + update: (db, urlOrKey) => + db + .update(Db.organizations) + .set( + organizationTarget.image === "icon" + ? { iconUrl: urlOrKey } + : { shareableLinkIconUrl: urlOrKey }, + ) + .where( + eq(Db.organizations.id, organizationTarget.organizationId), + ), + }); + } else { + const organizationId = collectionOrganizationId; + if (!organizationId) { + return yield* temporarilyUnavailable( + input.requestId, + "Collection organization is unavailable", + ); + } + const [collection] = + target.type === "folder" + ? yield* database.use((db) => + db + .select({ settings: Db.folders.settings }) + .from(Db.folders) + .where(eq(Db.folders.id, target.folderId)) + .limit(1), + ) + : yield* database.use((db) => + db + .select({ settings: Db.spaces.settings }) + .from(Db.spaces) + .where(eq(Db.spaces.id, target.spaceId)) + .limit(1), + ); + if (!collection) return yield* notFound(input.requestId); + const settings = collection.settings as { + publicPage?: { logoUrl?: string }; + } | null; + yield* imageUploads.applyUpdate({ + payload: image ? Option.some(image) : Option.none(), + existing: Option.fromNullable( + settings?.publicPage?.logoUrl as + | ImageUpload.ImageUrlOrKey + | undefined, + ), + keyPrefix: `organizations/${organizationId}/collections/${targetId}/logo`, + update: (db, urlOrKey) => { + const patch = JSON.stringify({ + publicPage: { + logoUrl: urlOrKey ?? null, + logoMode: urlOrKey ? "custom" : "cap", + }, + }); + return target.type === "folder" + ? db + .update(Db.folders) + .set({ + settings: sql`JSON_MERGE_PATCH(COALESCE(${Db.folders.settings}, '{}'), CAST(${patch} AS JSON))`, + }) + .where(eq(Db.folders.id, target.folderId)) + : db + .update(Db.spaces) + .set({ + settings: sql`JSON_MERGE_PATCH(COALESCE(${Db.spaces.settings}, '{}'), CAST(${patch} AS JSON))`, + }) + .where(eq(Db.spaces.id, target.spaceId)); + }, + }); + } + const now = new Date(); + return mutationResponse( + input.target.type === "profile" + ? "profile_image" + : input.target.type === "organization" + ? input.target.image === "icon" + ? "organization_icon" + : "shareable_link_icon" + : input.target.type === "folder" + ? "folder_logo" + : "space_logo", + targetId, + image ? "updated" : "deleted", + input.requestId, + now, + ); + }), + }); +}); + +const updateAgentCollectionPublicPage = Effect.fn( + "Agent.updateCollectionPublicPage", +)(function* (input: { + target: + | { type: "folder"; folderId: Folder.FolderId } + | { type: "space"; spaceId: Space.SpaceIdOrOrganisationId }; + payload: (typeof Agent.AgentCollectionPublicPageInput)["Type"]; + requestId: string; +}) { + yield* requireAgentWrites(input.requestId); + yield* requireUserConfirmedRequest(input.requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", input.requestId); + const { public: publicValue, ...publicPageValues } = input.payload; + const publicPage = Object.fromEntries( + Object.entries(publicPageValues).filter(([, value]) => value !== undefined), + ); + if (publicValue === undefined && Object.keys(publicPage).length === 0) { + return yield* badRequest( + input.requestId, + "At least one public page field is required", + ); + } + const management = yield* AgentManagement; + const access = + input.target.type === "folder" + ? yield* management + .getFolderAccess(principal.id, input.target.folderId) + .pipe( + Effect.map((access) => ({ + canManage: access.canManage, + organizationId: access.folder.organizationId, + })), + ) + : yield* management + .getSpaceAccess(principal.id, input.target.spaceId) + .pipe( + Effect.map((access) => ({ + canManage: access.canManage, + organizationId: access.organizationId, + })), + ); + if (!access.canManage) return yield* forbidden(input.requestId); + const organizationId = access.organizationId; + if (publicValue === true || Object.keys(publicPage).length > 0) { + const organization = normalizeOrganization( + yield* management.getOrganization(principal.id, organizationId), + principal.scopes, + ); + if (organization.billing.plan !== "pro") { + return yield* forbidden( + input.requestId, + "Cap Pro is required for public collection customization", + ); + } + } + const targetId = + input.target.type === "folder" + ? input.target.folderId + : input.target.spaceId; + return yield* runAgentMutation({ + principal, + operation: `update_${input.target.type}_public_page`, + idempotencyKey: yield* requestIdempotencyKey, + request: { target: input.target, payload: input.payload }, + requestId: input.requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const now = new Date(); + const settingsPatch = + Object.keys(publicPage).length > 0 + ? JSON.stringify({ publicPage }) + : null; + const result = + input.target.type === "folder" + ? await tx + .update(Db.folders) + .set({ + public: publicValue, + settings: settingsPatch + ? sql`JSON_MERGE_PATCH(COALESCE(${Db.folders.settings}, '{}'), CAST(${settingsPatch} AS JSON))` + : undefined, + updatedAt: now, + }) + .where(eq(Db.folders.id, input.target.folderId)) + : await tx + .update(Db.spaces) + .set({ + public: publicValue, + settings: settingsPatch + ? sql`JSON_MERGE_PATCH(COALESCE(${Db.spaces.settings}, '{}'), CAST(${settingsPatch} AS JSON))` + : undefined, + updatedAt: now, + }) + .where(eq(Db.spaces.id, input.target.spaceId)); + if (affectedRows(result) === 0) return { state: "not_found" }; + return { + state: "success", + response: mutationResponse( + `${input.target.type}_public_page`, + targetId, + "updated", + input.requestId, + now, + ), + }; + }, + }); +}); + +const queueAgentCapOperation = Effect.fn("Agent.queueCapOperation")( + function* (input: { + videoId: Video.VideoId; + kind: "duplicate_cap" | "delete_cap"; + scope: Agent.AgentScope; + requestId: string; + }) { + yield* requireAgentWrites(input.requestId); + yield* requireUserConfirmedRequest(input.requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, input.scope, input.requestId); + const operationId = nanoId(); + const destinationId = + input.kind === "duplicate_cap" ? Video.VideoId.make(nanoId()) : null; + const state = yield* runAgentMutation({ + principal, + operation: input.kind, + idempotencyKey: yield* requestIdempotencyKey, + request: { videoId: input.videoId }, + requestId: input.requestId, + decodeReplay: Schema.decodeUnknownSync(AgentOperationMutationState), + execute: async (tx) => { + const [video] = await tx + .select() + .from(Db.videos) + .where( + and( + eq(Db.videos.id, input.videoId), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + await tx.insert(Db.agentApiOperations).values({ + id: operationId, + userId: principal.id, + kind: input.kind, + resourceId: input.videoId, + resultResourceId: destinationId, + payload: { + snapshot: { + id: video.id, + ownerId: video.ownerId, + orgId: video.orgId, + name: video.name, + bucket: video.bucket, + storageIntegrationId: video.storageIntegrationId, + duration: video.duration, + width: video.width, + height: video.height, + fps: video.fps, + metadata: video.metadata, + public: video.public, + settings: video.settings, + transcriptionStatus: video.transcriptionStatus, + source: video.source, + folderId: video.folderId, + isScreenshot: video.isScreenshot, + skipProcessing: video.skipProcessing, + }, + destinationId, + }, + }); + return { + state: "success", + response: { operationId, requestId: input.requestId }, + }; + }, + }); + yield* Effect.tryPromise(() => + start(agentCapOperationWorkflow, [{ operationId: state.operationId }]), + ); + return yield* getAgentOperation(state.operationId, state.requestId); + }, +); + +const queueAgentOrganizationDelete = Effect.fn("Agent.queueOrganizationDelete")( + function* (organizationId: Organisation.OrganisationId, requestId: string) { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:manage", requestId); + const operationId = nanoId(); + const state = yield* runAgentMutation({ + principal, + operation: "delete_organization", + idempotencyKey: yield* requestIdempotencyKey, + request: { organizationId }, + requestId, + decodeReplay: Schema.decodeUnknownSync(AgentOperationMutationState), + execute: async (tx) => { + const [organization] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, organizationId), + eq(Db.organizations.ownerId, principal.id), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + if (!organization) return { state: "not_found" }; + await tx.insert(Db.agentApiOperations).values({ + id: operationId, + userId: principal.id, + kind: "delete_organization", + resourceId: organizationId, + resultResourceId: null, + payload: { organizationId }, + }); + return { + state: "success", + response: { operationId, requestId }, + }; + }, + }); + yield* Effect.tryPromise(() => + start(agentCapOperationWorkflow, [{ operationId: state.operationId }]), + ); + return yield* getAgentOperation(state.operationId, state.requestId); + }, +); + +const queueAgentOrganizationDomain = Effect.fn("Agent.queueOrganizationDomain")( + function* (input: { + organizationId: Organisation.OrganisationId; + kind: + | "set_organization_domain" + | "remove_organization_domain" + | "verify_organization_domain"; + domain?: string; + requestId: string; + }) { + yield* requireAgentWrites(input.requestId); + yield* requireUserConfirmedRequest(input.requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:manage", input.requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + input.organizationId, + ); + const operationId = nanoId(); + const state = yield* runAgentMutation({ + principal, + operation: input.kind, + idempotencyKey: yield* requestIdempotencyKey, + request: { + organizationId: input.organizationId, + domain: input.domain, + }, + requestId: input.requestId, + decodeReplay: Schema.decodeUnknownSync(AgentOperationMutationState), + execute: async (tx) => { + const [[organization], [membership], [account]] = await Promise.all([ + tx + .select({ ownerId: Db.organizations.ownerId }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, input.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"), + tx + .select({ role: Db.organizationMembers.role }) + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.organizationId, input.organizationId), + eq(Db.organizationMembers.userId, principal.id), + ), + ) + .limit(1), + tx + .select({ + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ]); + if (!organization || !account) return { state: "not_found" }; + if ( + organization.ownerId !== principal.id && + membership?.role !== "owner" && + membership?.role !== "admin" + ) { + return { state: "forbidden" }; + } + if (input.kind === "set_organization_domain" && !userIsPro(account)) { + return { state: "forbidden" }; + } + if (input.domain) { + const [conflict] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.customDomain, input.domain), + ne(Db.organizations.id, input.organizationId), + ), + ) + .limit(1); + if (conflict) return { state: "conflict" }; + } + await tx.insert(Db.agentApiOperations).values({ + id: operationId, + userId: principal.id, + kind: input.kind, + resourceId: input.organizationId, + resultResourceId: null, + payload: { + organizationId: input.organizationId, + domain: input.domain, + }, + }); + return { + state: "success", + response: { operationId, requestId: input.requestId }, + }; + }, + }); + yield* Effect.tryPromise(() => + start(agentCapOperationWorkflow, [{ operationId: state.operationId }]), + ); + return yield* getAgentOperation(state.operationId, state.requestId); + }, +); + +const queueAgentLoomImport = Effect.fn("Agent.queueLoomImport")( + function* (input: { + organizationId: Organisation.OrganisationId; + payload: (typeof Agent.AgentLoomImportInput)["Type"]; + requestId: string; + }) { + yield* requireAgentWrites(input.requestId); + yield* requireUserConfirmedRequest(input.requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:write", input.requestId); + const loomUrl = input.payload.loomUrl.trim(); + let parsedLoomUrl: URL; + try { + parsedLoomUrl = new URL(loomUrl); + } catch { + return yield* badRequest(input.requestId, "Loom URL is invalid"); + } + if ( + loomUrl.length > 2_048 || + (parsedLoomUrl.hostname !== "loom.com" && + !parsedLoomUrl.hostname.endsWith(".loom.com")) + ) { + return yield* badRequest(input.requestId, "Loom URL is invalid"); + } + const ownerEmail = input.payload.ownerEmail?.trim().toLowerCase(); + if ( + ownerEmail && + (ownerEmail.length > 255 || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ownerEmail)) + ) { + return yield* badRequest(input.requestId, "Owner email is invalid"); + } + const spaceName = input.payload.spaceName?.trim().replace(/\s+/g, " "); + if ( + spaceName !== undefined && + (spaceName.length === 0 || spaceName.length > 255) + ) { + return yield* badRequest( + input.requestId, + "Space name must be between 1 and 255 characters", + ); + } + const management = yield* AgentManagement; + yield* management.getMembership(principal.id, input.organizationId); + if (ownerEmail || spaceName) { + yield* management.requireOrganizationManager( + principal.id, + input.organizationId, + ); + } + if (ownerEmail) { + yield* requireScope(principal, "organizations:members", input.requestId); + } + if (spaceName) { + yield* requireScope(principal, "library:write", input.requestId); + } + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account) return yield* notFound(input.requestId); + if (!userIsPro(account)) { + return yield* forbidden( + input.requestId, + "Importing from Loom requires Cap Pro", + ); + } + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + headers.set(name, value); + } + const idempotencyKey = yield* requestIdempotencyKey; + const state = yield* runAgentExternalMutation({ + principal, + operation: "import_loom", + idempotencyKey, + request: { + organizationId: input.organizationId, + loomUrl, + ownerEmail: ownerEmail ?? null, + spaceName: spaceName ?? null, + }, + requestId: input.requestId, + decodeReplay: Schema.decodeUnknownSync(AgentOperationMutationState), + execute: (providerIdempotencyKey) => + Effect.gen(function* () { + if ( + yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.AGENT_LOOM_IMPORT, { + key: `loom-import:${principal.id}`, + headers, + }), + ) + ) { + return yield* rateLimited(input.requestId); + } + const download = yield* Effect.tryPromise({ + try: () => downloadLoomVideo(loomUrl), + catch: () => + temporarilyUnavailable( + input.requestId, + "Loom could not be reached", + ), + }); + if (!download.success || !download.videoId || !download.downloadUrl) { + return yield* badRequest( + input.requestId, + download.error ?? "The Loom video is unavailable", + ); + } + let ownerId = principal.id; + if (ownerEmail) { + const provisioned = yield* Effect.tryPromise({ + try: () => + provisionOrganizationInvitee({ + organizationId: input.organizationId, + email: ownerEmail, + invitedByUserId: principal.id, + role: "member", + }), + catch: () => + temporarilyUnavailable( + input.requestId, + "The Loom import owner could not be provisioned", + ), + }); + ownerId = provisioned.userId; + } + const loomVideoId = download.videoId; + const operationId = deterministicAgentId( + "loom_operation", + principal.id, + providerIdempotencyKey, + ); + const videoId = Video.VideoId.make( + deterministicAgentId( + "loom_video", + principal.id, + providerIdempotencyKey, + ), + ); + const writable = yield* Storage.getWritableAccessForUser( + ownerId, + input.organizationId, + ); + const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; + const now = new Date(); + const importState = yield* database.use((db) => + db.transaction(async (tx) => { + const [existingImport] = await tx + .select({ id: Db.importedVideos.id }) + .from(Db.importedVideos) + .where( + and( + eq(Db.importedVideos.orgId, input.organizationId), + eq(Db.importedVideos.source, "loom"), + eq(Db.importedVideos.sourceId, loomVideoId), + ), + ) + .limit(1) + .for("update"); + if (existingImport && existingImport.id !== videoId) { + return "conflict" as const; + } + const [existingOperation] = await tx + .select({ id: Db.agentApiOperations.id }) + .from(Db.agentApiOperations) + .where(eq(Db.agentApiOperations.id, operationId)) + .limit(1); + if (!existingOperation) { + if (!existingImport) { + await tx.insert(Db.videos).values({ + id: videoId, + name: + download.videoName?.slice(0, 255) ?? + `Loom Import - ${now.toISOString().slice(0, 10)}`, + ownerId, + orgId: input.organizationId, + source: { type: "webMP4" }, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull( + writable.storageIntegrationId, + ), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + duration: download.durationSeconds, + width: download.width, + height: download.height, + }); + await tx.insert(Db.videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + processingMessage: "Importing from Loom...", + }); + await tx.insert(Db.importedVideos).values({ + id: videoId, + orgId: input.organizationId, + source: "loom", + sourceId: loomVideoId, + }); + } + let spaceId: Space.SpaceIdOrOrganisationId | null = null; + if (spaceName) { + const [existingSpace] = await tx + .select({ id: Db.spaces.id }) + .from(Db.spaces) + .where( + and( + eq(Db.spaces.organizationId, input.organizationId), + sql`LOWER(${Db.spaces.name}) = ${spaceName.toLowerCase()}`, + ), + ) + .limit(1); + spaceId = + existingSpace?.id ?? + Space.SpaceId.make( + deterministicAgentId( + "loom_space", + input.organizationId, + spaceName.toLowerCase(), + ), + ); + if (!existingSpace) { + await tx.insert(Db.spaces).values({ + id: spaceId, + name: spaceName, + organizationId: input.organizationId, + createdById: principal.id, + }); + } + for (const [userId, role] of [ + [principal.id, "admin"], + [ownerId, ownerId === principal.id ? "admin" : "member"], + ] as const) { + await tx + .insert(Db.spaceMembers) + .values({ + id: deterministicAgentId( + "loom_space_member", + spaceId, + userId, + ), + spaceId, + userId, + role, + }) + .onDuplicateKeyUpdate({ + set: { role: sql`${Db.spaceMembers.role}` }, + }); + } + await tx.insert(Db.spaceVideos).values({ + id: deterministicAgentId( + "loom_space_video", + spaceId, + videoId, + ), + spaceId, + videoId, + addedById: principal.id, + }); + } + await tx.insert(Db.agentApiOperations).values({ + id: operationId, + userId: principal.id, + kind: "import_loom", + resourceId: input.organizationId, + resultResourceId: videoId, + payload: { + organizationId: input.organizationId, + videoId, + ownerId, + loomVideoId, + spaceId, + }, + }); + } + return "ready" as const; + }), + ); + if (importState === "conflict") { + return yield* new Agent.AgentConflictError({ + ...commonError( + input.requestId, + "This Loom video has already been imported", + ), + code: "CONFLICT", + }); + } + yield* Effect.tryPromise(() => + start(importLoomVideoWorkflow, [ + { + videoId, + userId: ownerId, + rawFileKey, + bucketId: Option.getOrNull(writable.bucketId), + loomVideoId, + agentOperationId: operationId, + }, + ]), + ).pipe(Effect.asVoid); + return { operationId, requestId: input.requestId }; + }), + }); + return yield* getAgentOperation(state.operationId, state.requestId); + }, +); + +const normalizeTranscriptCues = ( + cues: ReadonlyArray<(typeof Agent.AgentTranscriptCue)["Type"]>, +) => { + if (cues.length === 0 || cues.length > 50_000) return null; + let previousStartMs = -1; + const normalized: (typeof Agent.AgentTranscriptCue)["Type"][] = []; + for (const cue of cues) { + const text = cue.text.replace(/\s+/g, " ").trim(); + if ( + !Number.isSafeInteger(cue.startMs) || + !Number.isSafeInteger(cue.endMs) || + cue.startMs < 0 || + cue.endMs < cue.startMs || + cue.startMs < previousStartMs || + text.length === 0 || + text.length > 10_000 + ) { + return null; + } + normalized.push({ startMs: cue.startMs, endMs: cue.endMs, text }); + previousStartMs = cue.startMs; + } + const vtt = renderAgentVtt(normalized); + return Buffer.byteLength(vtt, "utf8") <= 10 * 1024 * 1024 + ? { cues: normalized, vtt } + : null; +}; + +const AgentHandlersLive = HttpApiBuilder.group( + Agent.AgentApiContract, + "agent", + (handlers) => + handlers + .handle("listCaps", ({ urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors(listCaps(urlParams, requestId), requestId); + }) + .handle("getCap", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + getViewableCap(path.id).pipe(Effect.map(({ cap }) => cap)), + requestId, + ); + }) + .handle("getContext", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors(getContext(path.id, requestId), requestId); + }) + .handle("getStatus", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* getViewableVideo(path.id); + return getStatus(yield* getStatusRow(path.id)); + }), + requestId, + ); + }) + .handle("getTranscript", ({ path, urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const { video, cap } = yield* getViewableCap(path.id); + if (cap.capabilities.transcript.reason === "CONTENT_DISABLED") { + return yield* contentDisabled(requestId, "Transcript"); + } + if (!cap.capabilities.transcript.allowed) { + return yield* notReady(requestId, "Transcript is not ready"); + } + const transcript = yield* readTranscript(video, requestId); + switch (urlParams.format ?? "text") { + case "json": + return HttpServerResponse.unsafeJson({ + id: path.id, + revision: transcript.revision, + cues: transcript.cues, + requestId, + }); + case "vtt": + return HttpServerResponse.text(transcript.vtt, { + headers: { "Content-Type": "text/vtt; charset=utf-8" }, + }); + case "text": + return HttpServerResponse.text(transcript.text, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + }), + requestId, + ); + }) + .handle("getDownload", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const { cap } = yield* getViewableCap(path.id); + if (!cap.capabilities.download.allowed) { + return yield* forbidden(requestId); + } + const videos = yield* Videos; + const principal = yield* Agent.AgentPrincipal; + const result = yield* videos + .getDownloadInfo(path.id) + .pipe( + Effect.provideService(CurrentUser, toCurrentUser(principal)), + ); + if (Option.isNone(result)) { + return yield* notReady(requestId, "Download is not ready"); + } + return { + fileName: result.value.fileName, + url: result.value.downloadUrl, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + requestId, + }; + }), + requestId, + ); + }) + .handle("unlockCap", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors(unlockCap(path.id, requestId), requestId); + }) + .handle("createComment", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + const { cap, row } = yield* getViewableCap(path.id); + if (!cap.capabilities.comment.allowed) { + return yield* capabilityFailure( + requestId, + cap.capabilities.comment, + ); + } + return yield* createAgentFeedback({ + videoId: path.id, + principal, + type: "text", + content: payload.content, + timestampMs: payload.timestampMs ?? null, + parentCommentId: null, + idempotencyKey: yield* requestIdempotencyKey, + requestId, + durationMs: + row.duration === null ? null : Math.round(row.duration * 1_000), + }); + }), + requestId, + ); + }) + .handle("createReply", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + const { cap, row } = yield* getViewableCap(path.id); + if (!cap.capabilities.comment.allowed) { + return yield* capabilityFailure( + requestId, + cap.capabilities.comment, + ); + } + return yield* createAgentFeedback({ + videoId: path.id, + principal, + type: "text", + content: payload.content, + timestampMs: payload.timestampMs ?? null, + parentCommentId: path.commentId, + idempotencyKey: yield* requestIdempotencyKey, + requestId, + durationMs: + row.duration === null ? null : Math.round(row.duration * 1_000), + }); + }), + requestId, + ); + }) + .handle("createReaction", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + const { cap, row } = yield* getViewableCap(path.id); + if (!cap.capabilities.react.allowed) { + return yield* capabilityFailure( + requestId, + cap.capabilities.react, + ); + } + return yield* createAgentFeedback({ + videoId: path.id, + principal, + type: "emoji", + content: payload.content, + timestampMs: payload.timestampMs ?? null, + parentCommentId: null, + idempotencyKey: yield* requestIdempotencyKey, + requestId, + durationMs: + row.duration === null ? null : Math.round(row.duration * 1_000), + }); + }), + requestId, + ); + }) + .handle("updateCap", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + const { cap } = yield* getViewableCap(path.id); + if ( + (payload.title !== undefined && + !cap.capabilities.editTitle.allowed) || + (payload.public !== undefined && + !cap.capabilities.editVisibility.allowed) + ) { + return yield* forbidden(requestId); + } + return yield* updateAgentCap({ + videoId: path.id, + principal, + title: payload.title, + public: payload.public, + idempotencyKey: yield* requestIdempotencyKey, + requestId, + }); + }), + requestId, + ); + }), +); + +const AgentManagementHandlersLive = HttpApiBuilder.group( + Agent.AgentApiContract, + "agentManagement", + (handlers) => + handlers + .handle("getMe", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "profile:read", requestId); + const management = yield* AgentManagement; + const account = yield* management.getAccount(principal.id); + return { + ...account, + image: account.image ?? null, + activeOrganizationId: account.activeOrganizationId ?? null, + defaultOrganizationId: account.defaultOrganizationId ?? null, + createdAt: account.createdAt.toISOString(), + capabilities: profileCapabilities(principal.scopes), + requestId, + }; + }), + requestId, + ); + }) + .handle("listOrganizations", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:read", requestId); + const management = yield* AgentManagement; + const rows = yield* management.listOrganizations(principal.id); + return { + organizations: rows.map((row) => + normalizeOrganization(row, principal.scopes), + ), + requestId, + }; + }), + requestId, + ); + }) + .handle("getOrganization", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:read", requestId); + const management = yield* AgentManagement; + const row = yield* management.getOrganization( + principal.id, + path.organizationId, + ); + return { + organization: normalizeOrganization(row, principal.scopes), + requestId, + }; + }), + requestId, + ); + }) + .handle("listOrganizationMembers", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:read", requestId); + const management = yield* AgentManagement; + const caller = yield* management.getMembership( + principal.id, + path.organizationId, + ); + const rows = yield* management.listMembers( + principal.id, + path.organizationId, + ); + const callerCapabilities = organizationCapabilities( + caller.role, + principal.scopes, + ); + return { + members: rows.map((row) => { + const role = + row.role === "owner" || row.role === "admin" + ? row.role + : "member"; + const canChange = + role !== "owner" && callerCapabilities.manageMembers.allowed; + return { + ...row, + role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: { + update: { + ...callerCapabilities.manageMembers, + allowed: canChange, + reason: canChange + ? null + : (callerCapabilities.manageMembers.reason ?? + "OWNER_ONLY"), + }, + remove: { + ...callerCapabilities.manageMembers, + allowed: canChange, + reason: canChange + ? null + : (callerCapabilities.manageMembers.reason ?? + "OWNER_ONLY"), + sideEffect: "destructive" as const, + }, + }, + }; + }), + requestId, + }; + }), + requestId, + ); + }) + .handle("listOrganizationInvites", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:read", requestId); + const management = yield* AgentManagement; + const caller = yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + const rows = yield* management.listInvites( + principal.id, + path.organizationId, + ); + const capability = organizationCapabilities( + caller.role, + principal.scopes, + ).manageMembers; + return { + invites: rows.map((row) => ({ + id: row.id, + invitedEmail: row.invitedEmail, + role: + row.role === "owner" || row.role === "admin" + ? row.role + : "member", + status: row.status, + expiresAt: row.expiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: { revoke: capability }, + })), + requestId, + }; + }), + requestId, + ); + }) + .handle("listFolders", ({ path, urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:read", requestId); + const management = yield* AgentManagement; + const spaceId = urlParams.spaceId + ? Space.SpaceId.make(urlParams.spaceId) + : null; + const parentId = + urlParams.parentId === "root" + ? null + : urlParams.parentId + ? Folder.FolderId.make(urlParams.parentId) + : undefined; + const rows = yield* management.listFolders( + principal.id, + path.organizationId, + spaceId, + parentId, + ); + const canManage = + spaceId === null + ? true + : String(spaceId) === String(path.organizationId) + ? (yield* management.getMembership( + principal.id, + path.organizationId, + )).role !== "member" + : (yield* management.getSpaceAccess(principal.id, spaceId)) + .canManage; + return { + folders: rows.map((row) => ({ + id: row.id, + name: row.name, + color: row.color, + public: row.public, + organizationId: row.organizationId, + createdById: row.createdById, + parentId: row.parentId, + spaceId: row.spaceId, + settings: row.settings ?? null, + publicPage: row.settings?.publicPage ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: libraryCapabilities( + canManage && + (spaceId !== null || row.createdById === principal.id), + principal.scopes, + ), + })), + requestId, + }; + }), + requestId, + ); + }) + .handle("listSpaces", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:read", requestId); + const management = yield* AgentManagement; + const membership = yield* management.getMembership( + principal.id, + path.organizationId, + ); + const rows = yield* management.listSpaces( + principal.id, + path.organizationId, + ); + return { + spaces: rows.map((row) => { + const canManage = + row.createdById === principal.id || + row.role === "admin" || + membership.role !== "member"; + return { + id: row.id, + name: row.name, + description: row.description, + organizationId: row.organizationId, + createdById: row.createdById, + primary: row.primary, + privacy: row.privacy, + public: row.public, + protected: row.hasPassword, + icon: row.icon, + settings: agentViewerSettings(row.settings), + publicPage: row.settings?.publicPage ?? null, + role: row.role, + counts: { + members: row.memberCount, + caps: row.capCount, + folders: row.folderCount, + }, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: libraryCapabilities( + canManage, + principal.scopes, + ), + }; + }), + requestId, + }; + }), + requestId, + ); + }) + .handle("listSpaceMembers", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:read", requestId); + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + const rows = yield* management.listSpaceMembers( + principal.id, + path.spaceId, + ); + const capability = libraryCapabilities( + access.canManage, + principal.scopes, + ).manageMembers; + return { + members: rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities: { + update: capability, + remove: { + ...capability, + sideEffect: "destructive" as const, + }, + }, + })), + requestId, + }; + }), + requestId, + ); + }) + .handle("listNotifications", ({ urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "notifications:read", requestId); + const limit = parseAgentLimit(urlParams.limit); + if (limit === null) { + return yield* badRequest(requestId, "limit must be positive"); + } + const cursor = decodeNotificationCursor(urlParams.cursor); + if (cursor === undefined) { + return yield* badRequest(requestId, "cursor is invalid"); + } + const management = yield* AgentManagement; + const { rows, unreadCount } = yield* management.listNotifications( + principal.id, + limit, + cursor, + urlParams.unread === undefined + ? null + : urlParams.unread === "true", + ); + const page = rows.slice(0, limit); + const last = page.at(-1); + return { + notifications: page.map((row) => ({ + id: row.id, + organizationId: row.orgId, + type: row.type, + data: row.data, + videoId: row.videoId, + readAt: row.readAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + })), + nextCursor: + rows.length > limit && last + ? encodeNotificationCursor({ + createdAt: last.createdAt, + id: last.id, + }) + : null, + unreadCount, + requestId, + }; + }), + requestId, + ); + }) + .handle("getNotificationPreferences", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "notifications:read", requestId); + const management = yield* AgentManagement; + const preferences = yield* management.getNotificationPreferences( + principal.id, + ); + return { + pauseComments: preferences?.pauseComments ?? false, + pauseReplies: preferences?.pauseReplies ?? false, + pauseViews: preferences?.pauseViews ?? false, + pauseReactions: preferences?.pauseReactions ?? false, + pauseAnonymousViews: preferences?.pauseAnonViews ?? false, + requestId, + }; + }), + requestId, + ); + }) + .handle("getAnalytics", ({ urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "analytics:read", requestId); + const organizationId = Organisation.OrganisationId.make( + urlParams.organizationId, + ); + const spaceId = urlParams.spaceId + ? Space.SpaceId.make(urlParams.spaceId) + : null; + const capId = urlParams.capId + ? Video.VideoId.make(urlParams.capId) + : null; + const management = yield* AgentManagement; + yield* management.assertAnalyticsAccess( + principal.id, + organizationId, + spaceId, + capId, + ); + const range = urlParams.range ?? "month"; + const dashboardRange = { + day: "24h", + week: "7d", + month: "30d", + year: "lifetime", + } as const; + const data = yield* Effect.tryPromise(() => + getOrgAnalyticsData( + organizationId, + dashboardRange[range], + spaceId ?? undefined, + capId ?? undefined, + ), + ); + return { + organizationId, + spaceId, + capId, + range, + data, + requestId, + }; + }), + requestId, + ); + }) + .handle("getCapSettings", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + const { row, rules } = yield* getViewableCap(path.id); + const canUpdate = + row.ownerId === principal.id && hasScope(principal, "caps:write"); + return { + id: path.id, + overrides: agentViewerSettings(row.videoSettings), + effective: { + ...agentViewerSettings(rules.settings), + defaultPlaybackSpeed: + row.videoSettings?.defaultPlaybackSpeed ?? + row.organizationSettings?.defaultPlaybackSpeed ?? + null, + }, + inherited: rules.inheritedSettings, + capabilities: { + read: agentAction({ allowed: true }), + update: agentAction({ + allowed: canUpdate, + reason: + row.ownerId !== principal.id + ? "OWNER_ONLY" + : canUpdate + ? null + : "SCOPE_REQUIRED", + requiredScopes: ["caps:write"], + confirmation: "user", + sideEffect: "write", + }), + }, + requestId, + }; + }), + requestId, + ); + }) + .handle("getCapShares", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + const { row, rules } = yield* getViewableCap(path.id); + if (row.ownerId !== principal.id) { + return yield* forbidden( + requestId, + "Only the Cap owner can inspect sharing targets", + ); + } + const database = yield* Database; + const [organizations, spaces] = yield* database.use((db) => + Promise.all([ + db + .select({ + organizationId: Db.sharedVideos.organizationId, + organizationName: Db.organizations.name, + folderId: Db.sharedVideos.folderId, + sharedAt: Db.sharedVideos.sharedAt, + }) + .from(Db.sharedVideos) + .innerJoin( + Db.organizations, + eq(Db.sharedVideos.organizationId, Db.organizations.id), + ) + .where(eq(Db.sharedVideos.videoId, path.id)) + .orderBy(Db.organizations.name, Db.sharedVideos.id), + db + .select({ + spaceId: Db.spaceVideos.spaceId, + spaceName: Db.spaces.name, + organizationId: Db.spaces.organizationId, + folderId: Db.spaceVideos.folderId, + addedAt: Db.spaceVideos.addedAt, + }) + .from(Db.spaceVideos) + .innerJoin( + Db.spaces, + eq(Db.spaceVideos.spaceId, Db.spaces.id), + ) + .where(eq(Db.spaceVideos.videoId, path.id)) + .orderBy(Db.spaces.name, Db.spaceVideos.id), + ]), + ); + const canUpdate = hasScope(principal, "caps:write"); + return { + id: path.id, + public: row.public, + protected: row.hasPassword || rules.hasInheritedPassword, + organizations: organizations.map((share) => ({ + ...share, + sharedAt: share.sharedAt.toISOString(), + })), + spaces: spaces.map((share) => ({ + ...share, + addedAt: share.addedAt.toISOString(), + })), + capabilities: { + update: agentAction({ + allowed: canUpdate, + reason: canUpdate ? null : "SCOPE_REQUIRED", + requiredScopes: ["caps:write"], + confirmation: "user", + sideEffect: "write", + }), + }, + requestId, + }; + }), + requestId, + ); + }) + .handle("listStorageIntegrations", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "integrations:read", requestId); + const management = yield* AgentManagement; + const membership = yield* management.getMembership( + principal.id, + path.organizationId, + ); + const database = yield* Database; + const rows = yield* management.listStorageIntegrations( + principal.id, + path.organizationId, + ); + const s3Rows = yield* database.use((db) => + db + .select({ + id: Db.s3Buckets.id, + provider: Db.s3Buckets.provider, + active: Db.s3Buckets.active, + createdAt: Db.s3Buckets.createdAt, + updatedAt: Db.s3Buckets.updatedAt, + }) + .from(Db.s3Buckets) + .where( + and( + eq(Db.s3Buckets.organizationId, path.organizationId), + eq(Db.s3Buckets.active, true), + ), + ) + .orderBy(desc(Db.s3Buckets.updatedAt)) + .limit(1), + ); + const capabilities = integrationCapabilities( + membership.role !== "member", + principal.scopes, + ); + return { + integrations: [ + ...rows, + ...s3Rows.map((row) => ({ + ...row, + displayName: "S3-compatible storage", + status: "active", + })), + ].map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities, + })), + requestId, + }; + }), + requestId, + ); + }) + .handle("listOrganizationGoogleDriveFolders", ({ path, urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "integrations:read", requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account || !userIsPro(account)) { + return yield* forbidden( + requestId, + "Cap Pro is required to manage storage integrations", + ); + } + const drive = yield* getAgentOrganizationDrive(path.organizationId); + if (!drive || drive.status !== "active") { + return yield* notReady( + requestId, + "Google Drive is not connected", + ); + } + const config = yield* parseAgentDriveConfig(drive.encryptedConfig); + const accessToken = yield* getGoogleDriveAccessToken(config); + const parentId = urlParams.parentId?.trim() || "root"; + const escapedParentId = parentId + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'"); + const url = new URL("https://www.googleapis.com/drive/v3/files"); + url.searchParams.set( + "q", + `'${escapedParentId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`, + ); + url.searchParams.set("fields", "files(id,name,driveId)"); + url.searchParams.set("spaces", "drive"); + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); + if (config.driveId) { + url.searchParams.set("corpora", "drive"); + url.searchParams.set("driveId", config.driveId); + } + const body = yield* Effect.tryPromise(async () => { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error( + `Google Drive returned HTTP ${response.status}`, + ); + } + return (await response.json()) as { + files?: Array<{ + id?: string; + name?: string; + driveId?: string | null; + }>; + }; + }); + return { + folders: + body.files?.flatMap((folder) => + folder.id && folder.name + ? [ + { + id: folder.id, + name: folder.name, + driveId: folder.driveId ?? null, + driveName: null, + }, + ] + : [], + ) ?? [], + requestId, + }; + }), + requestId, + ); + }) + .handle("getOrganizationBilling", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "billing:read", requestId); + const management = yield* AgentManagement; + const billing = yield* management.getBilling( + principal.id, + path.organizationId, + ); + const organization = normalizeOrganization( + billing.organization, + principal.scopes, + ); + return { + organizationId: path.organizationId, + plan: organization.billing.plan, + status: organization.billing.status, + managedExternally: + billing.organization.ownerThirdPartySubscriptionId !== null, + seats: { + total: billing.totalSeats, + assigned: billing.assignedSeats, + }, + capabilities: organizationCapabilities( + billing.membership.role, + principal.scopes, + ), + requestId, + }; + }), + requestId, + ); + }) + .handle("listDeveloperApps", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:read", requestId); + const management = yield* AgentManagement; + const rows = yield* management.listDeveloperApps(principal.id); + const capabilities = developerCapabilities(principal.scopes); + return { + apps: rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + capabilities, + })), + requestId, + }; + }), + requestId, + ); + }) + .handle("getDeveloperAppContext", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:read", requestId); + const management = yield* AgentManagement; + const context = yield* management.getDeveloperAppContext( + principal.id, + path.appId, + ); + return { + app: { + ...context.app, + createdAt: context.app.createdAt.toISOString(), + updatedAt: context.app.updatedAt.toISOString(), + capabilities: developerCapabilities(principal.scopes), + }, + domains: context.domains.map((domain) => ({ + ...domain, + createdAt: domain.createdAt.toISOString(), + })), + keys: context.keys.map((key) => ({ + ...key, + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + revokedAt: key.revokedAt?.toISOString() ?? null, + createdAt: key.createdAt.toISOString(), + })), + usage: { + videoCount: context.videoCount, + storageMinutes: context.storageMinutes, + }, + credits: context.credits, + requestId, + }; + }), + requestId, + ); + }) + .handle("listDeveloperVideos", ({ path, urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:read", requestId); + const limit = parseAgentLimit(urlParams.limit); + const decodedCursor = decodeAgentCursor(urlParams.cursor); + if (limit === null || decodedCursor === undefined) { + return yield* badRequest(requestId, "Pagination is invalid"); + } + const cursor = decodedCursor + ? { + createdAt: new Date(decodedCursor.updatedAt), + id: decodedCursor.id, + } + : null; + const externalUserId = urlParams.userId?.trim() || null; + if ((externalUserId?.length ?? 0) > 255) { + return yield* badRequest(requestId, "User ID is too long"); + } + const management = yield* AgentManagement; + const rows = yield* management.listDeveloperVideos( + principal.id, + path.appId, + limit, + cursor, + externalUserId, + ); + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + const next = hasMore ? page.at(-1) : undefined; + const capabilities = developerCapabilities(principal.scopes); + return { + videos: page.map((video) => ({ + id: video.id, + appId: video.appId, + externalUserId: video.externalUserId, + name: video.name, + durationSeconds: video.duration, + width: video.width, + height: video.height, + fps: video.fps, + transcriptionStatus: video.transcriptionStatus, + createdAt: video.createdAt.toISOString(), + updatedAt: video.updatedAt.toISOString(), + capabilities, + })), + nextCursor: next + ? encodeAgentCursor({ + updatedAt: next.createdAt.toISOString(), + id: next.id, + }) + : null, + requestId, + }; + }), + requestId, + ); + }) + .handle("listDeveloperTransactions", ({ path, urlParams }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:read", requestId); + const limit = parseAgentLimit(urlParams.limit); + const decodedCursor = decodeAgentCursor(urlParams.cursor); + if (limit === null || decodedCursor === undefined) { + return yield* badRequest(requestId, "Pagination is invalid"); + } + const cursor = decodedCursor + ? { + createdAt: new Date(decodedCursor.updatedAt), + id: decodedCursor.id, + } + : null; + const management = yield* AgentManagement; + const rows = yield* management.listDeveloperTransactions( + principal.id, + path.appId, + limit, + cursor, + ); + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + const next = hasMore ? page.at(-1) : undefined; + return { + transactions: page.map((transaction) => ({ + ...transaction, + referenceId: transaction.referenceId ?? null, + referenceType: transaction.referenceType ?? null, + createdAt: transaction.createdAt.toISOString(), + })), + nextCursor: next + ? encodeAgentCursor({ + updatedAt: next.createdAt.toISOString(), + id: next.id, + }) + : null, + requestId, + }; + }), + requestId, + ); + }) + .handle("createDeveloperApp", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + yield* requireScope(principal, "developer:secrets", requestId); + const name = payload.name.trim(); + if (name.length === 0 || name.length > 255) { + return yield* badRequest( + requestId, + "Developer app name is invalid", + ); + } + const result = yield* runAgentMutation({ + principal, + operation: "create_developer_app", + idempotencyKey: yield* requestIdempotencyKey, + request: { name, environment: payload.environment }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + DeveloperKeyOperationResult, + ), + execute: async (tx) => { + const appId = nanoId(); + const publicKeyId = nanoId(); + const secretKeyId = nanoId(); + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const [ + publicKeyHash, + secretKeyHash, + encryptedPublicKey, + encryptedSecretKey, + ] = await Promise.all([ + hashKey(publicKeyRaw), + hashKey(secretKeyRaw), + encrypt(publicKeyRaw), + encrypt(secretKeyRaw), + ]); + await tx.insert(Db.developerApps).values({ + id: appId, + ownerId: principal.id, + name, + environment: payload.environment, + }); + await tx.insert(Db.developerApiKeys).values([ + { + id: publicKeyId, + appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: encryptedPublicKey, + }, + { + id: secretKeyId, + appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: encryptedSecretKey, + }, + ]); + await tx.insert(Db.developerCreditAccounts).values({ + id: nanoId(), + appId, + ownerId: principal.id, + }); + return { + state: "success", + response: { appId, publicKeyId, secretKeyId }, + }; + }, + }); + return yield* readDeveloperCredentials( + principal, + result, + requestId, + ); + }), + requestId, + ); + }) + .handle("updateDeveloperApp", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + if (Object.values(payload).every((value) => value === undefined)) { + return yield* badRequest( + requestId, + "At least one developer app field is required", + ); + } + const name = payload.name?.trim(); + if ( + (name !== undefined && + (name.length === 0 || name.length > 255)) || + (payload.logoUrl?.length ?? 0) > 1024 + ) { + return yield* badRequest( + requestId, + "Developer app fields are invalid", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_developer_app", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, ...payload, name }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1) + .for("update"); + if (!app) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.developerApps) + .set({ + name, + environment: payload.environment, + logoUrl: payload.logoUrl, + updatedAt: now, + }) + .where(eq(Db.developerApps.id, path.appId)); + return { + state: "success", + response: mutationResponse( + "developer_app", + path.appId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("deleteDeveloperApp", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "delete_developer_app", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1) + .for("update"); + if (!app) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.developerApiKeys) + .set({ revokedAt: now }) + .where( + and( + eq(Db.developerApiKeys.appId, path.appId), + isNull(Db.developerApiKeys.revokedAt), + ), + ); + await tx + .update(Db.developerApps) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(Db.developerApps.id, path.appId)); + return { + state: "success", + response: mutationResponse( + "developer_app", + path.appId, + "deleted", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("addDeveloperDomain", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + const domain = payload.domain.trim().toLowerCase(); + if (!/^https?:\/\/[a-z0-9.-]+(:[0-9]+)?$/.test(domain)) { + return yield* badRequest( + requestId, + "Domain must be a valid HTTP or HTTPS origin", + ); + } + return yield* runAgentMutation({ + principal, + operation: "add_developer_domain", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, domain }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1) + .for("update"); + if (!app) return { state: "not_found" }; + const id = deterministicAgentId( + "developer_domain", + path.appId, + domain, + ); + await tx + .insert(Db.developerAppDomains) + .values({ id, appId: path.appId, domain }) + .onDuplicateKeyUpdate({ + set: { domain: sql`${Db.developerAppDomains.domain}` }, + }); + return { + state: "success", + response: mutationResponse( + "developer_domain", + id, + "created", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("removeDeveloperDomain", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "remove_developer_domain", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1); + if (!app) return { state: "not_found" }; + const result = await tx + .delete(Db.developerAppDomains) + .where( + and( + eq(Db.developerAppDomains.id, path.domainId), + eq(Db.developerAppDomains.appId, path.appId), + ), + ); + if (affectedRows(result) === 0) return { state: "not_found" }; + return { + state: "success", + response: mutationResponse( + "developer_domain", + path.domainId, + "deleted", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("rotateDeveloperKeys", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:secrets", requestId); + const result = yield* runAgentMutation({ + principal, + operation: "rotate_developer_keys", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: Schema.decodeUnknownSync( + DeveloperKeyOperationResult, + ), + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1) + .for("update"); + if (!app) return { state: "not_found" }; + const publicKeyId = nanoId(); + const secretKeyId = nanoId(); + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const [ + publicKeyHash, + secretKeyHash, + encryptedPublicKey, + encryptedSecretKey, + ] = await Promise.all([ + hashKey(publicKeyRaw), + hashKey(secretKeyRaw), + encrypt(publicKeyRaw), + encrypt(secretKeyRaw), + ]); + const now = new Date(); + await tx + .update(Db.developerApiKeys) + .set({ revokedAt: now }) + .where( + and( + eq(Db.developerApiKeys.appId, path.appId), + isNull(Db.developerApiKeys.revokedAt), + ), + ); + await tx.insert(Db.developerApiKeys).values([ + { + id: publicKeyId, + appId: path.appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: encryptedPublicKey, + }, + { + id: secretKeyId, + appId: path.appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: encryptedSecretKey, + }, + ]); + return { + state: "success", + response: { + appId: path.appId, + publicKeyId, + secretKeyId, + }, + }; + }, + }); + return yield* readDeveloperCredentials( + principal, + result, + requestId, + ); + }), + requestId, + ); + }) + .handle("updateDeveloperAutoTopUp", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + yield* requireScope(principal, "billing:write", requestId); + if ( + (payload.thresholdMicroCredits !== undefined && + (!Number.isSafeInteger(payload.thresholdMicroCredits) || + payload.thresholdMicroCredits < 0)) || + (payload.amountCents !== undefined && + (!Number.isSafeInteger(payload.amountCents) || + payload.amountCents <= 0 || + payload.amountCents > 100_000)) + ) { + return yield* badRequest( + requestId, + "Auto top-up values are invalid", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_developer_auto_top_up", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [app] = await tx + .select({ id: Db.developerApps.id }) + .from(Db.developerApps) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1); + if (!app) return { state: "not_found" }; + const now = new Date(); + const result = await tx + .update(Db.developerCreditAccounts) + .set({ + autoTopUpEnabled: payload.enabled, + autoTopUpThresholdMicroCredits: + payload.thresholdMicroCredits, + autoTopUpAmountCents: payload.amountCents, + updatedAt: now, + }) + .where(eq(Db.developerCreditAccounts.appId, path.appId)); + if (affectedRows(result) === 0) return { state: "not_found" }; + return { + state: "success", + response: mutationResponse( + "developer_auto_top_up", + path.appId, + payload.enabled ? "enabled" : "disabled", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createDeveloperCreditsCheckout", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + yield* requireScope(principal, "billing:write", requestId); + if ( + !Number.isSafeInteger(payload.amountCents) || + payload.amountCents < 500 || + payload.amountCents > 100_000 + ) { + return yield* badRequest( + requestId, + "Developer credit purchase must be between $5 and $1,000", + ); + } + return yield* runAgentExternalMutation({ + principal, + operation: "create_developer_credits_checkout", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, amountCents: payload.amountCents }, + requestId, + decodeReplay: decodeBrowserActionResponse, + execute: (providerIdempotencyKey) => + Effect.gen(function* () { + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + id: Db.developerCreditAccounts.id, + }) + .from(Db.developerApps) + .innerJoin( + Db.developerCreditAccounts, + eq( + Db.developerCreditAccounts.appId, + Db.developerApps.id, + ), + ) + .where( + and( + eq(Db.developerApps.id, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + ), + ) + .limit(1), + ); + if (!account) return yield* notFound(requestId); + const customerId = yield* ensureAgentStripeCustomer( + principal, + providerIdempotencyKey, + requestId, + ); + yield* database.use((db) => + db + .update(Db.developerCreditAccounts) + .set({ stripeCustomerId: customerId }) + .where(eq(Db.developerCreditAccounts.id, account.id)), + ); + const session = yield* Effect.tryPromise(() => + stripe().checkout.sessions.create( + { + customer: customerId, + line_items: [ + { + price_data: { + currency: "usd", + product: + STRIPE_DEVELOPER_CREDITS_PRODUCT_ID[ + buildEnv.NEXT_PUBLIC_IS_CAP + ? "production" + : "development" + ], + unit_amount: payload.amountCents, + }, + quantity: 1, + }, + ], + mode: "payment", + success_url: `${serverEnv().WEB_URL}/cli/complete?developerCredits=success`, + cancel_url: `${serverEnv().WEB_URL}/cli/complete?developerCredits=cancelled`, + metadata: { + type: "developer_credits", + appId: path.appId, + accountId: account.id, + amountCents: String(payload.amountCents), + userId: principal.id, + }, + }, + { + idempotencyKey: `${providerIdempotencyKey}:checkout`, + }, + ), + ); + if (!session.url) { + return yield* temporarilyUnavailable( + requestId, + "Developer credits checkout is unavailable", + ); + } + return { + action: "developer_credits_checkout", + url: session.url, + requestId, + }; + }), + }); + }), + requestId, + ); + }) + .handle("deleteDeveloperVideo", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "developer:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "delete_developer_video", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.developerVideos.id }) + .from(Db.developerVideos) + .innerJoin( + Db.developerApps, + eq(Db.developerVideos.appId, Db.developerApps.id), + ) + .where( + and( + eq(Db.developerVideos.id, path.videoId), + eq(Db.developerVideos.appId, path.appId), + eq(Db.developerApps.ownerId, principal.id), + isNull(Db.developerApps.deletedAt), + isNull(Db.developerVideos.deletedAt), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.developerVideos) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(Db.developerVideos.id, path.videoId)); + return { + state: "success", + response: mutationResponse( + "developer_video", + path.videoId, + "deleted", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createSubscriptionCheckout", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "billing:write", requestId); + const management = yield* AgentManagement; + const membership = yield* management.getMembership( + principal.id, + path.organizationId, + ); + if (membership.role !== "owner") { + return yield* forbidden( + requestId, + "Only the organization owner can manage billing", + ); + } + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + const [members] = yield* database.use((db) => + db + .select({ + memberCount: sql`COUNT(*)`.mapWith(Number), + }) + .from(Db.organizationMembers) + .where( + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ); + if (!account) return yield* notFound(requestId); + if (userIsPro(account)) { + return yield* badRequest( + requestId, + "The organization owner already has Cap Pro", + ); + } + const quantity = + payload.quantity ?? Math.max(1, members?.memberCount ?? 1); + if ( + !Number.isSafeInteger(quantity) || + quantity < 1 || + quantity > 1_000 + ) { + return yield* badRequest( + requestId, + "Subscription quantity must be between 1 and 1,000", + ); + } + return yield* runAgentExternalMutation({ + principal, + operation: "create_subscription_checkout", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, interval: payload.interval, quantity }, + requestId, + decodeReplay: decodeBrowserActionResponse, + execute: (providerIdempotencyKey) => + Effect.gen(function* () { + const customerId = yield* ensureAgentStripeCustomer( + principal, + providerIdempotencyKey, + requestId, + ); + const environment = + serverEnv().VERCEL_ENV === "production" + ? "production" + : "development"; + const session = yield* Effect.tryPromise(() => + stripe().checkout.sessions.create( + { + customer: customerId, + line_items: [ + { + price: + STRIPE_PLAN_IDS[environment][payload.interval], + quantity, + }, + ], + mode: "subscription", + success_url: `${serverEnv().WEB_URL}/cli/complete?subscription=success`, + cancel_url: `${serverEnv().WEB_URL}/cli/complete?subscription=cancelled`, + allow_promotion_codes: true, + metadata: { + platform: "agent", + organizationId: path.organizationId, + userId: principal.id, + }, + }, + { + idempotencyKey: `${providerIdempotencyKey}:checkout`, + }, + ), + ); + if (!session.url) { + return yield* temporarilyUnavailable( + requestId, + "Subscription checkout is unavailable", + ); + } + return { + action: "subscription_checkout", + url: session.url, + requestId, + }; + }), + }); + }), + requestId, + ); + }) + .handle("createBillingPortal", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "billing:write", requestId); + const management = yield* AgentManagement; + const membership = yield* management.getMembership( + principal.id, + path.organizationId, + ); + if (membership.role !== "owner") { + return yield* forbidden( + requestId, + "Only the organization owner can manage billing", + ); + } + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account) return yield* notFound(requestId); + if (account.thirdPartyStripeSubscriptionId) { + return yield* badRequest( + requestId, + "This subscription is managed by an external provider", + ); + } + return yield* runAgentExternalMutation({ + principal, + operation: "create_billing_portal", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeBrowserActionResponse, + execute: (providerIdempotencyKey) => + Effect.gen(function* () { + const customerId = yield* ensureAgentStripeCustomer( + principal, + providerIdempotencyKey, + requestId, + ); + const session = yield* Effect.tryPromise(() => + stripe().billingPortal.sessions.create( + { + customer: customerId, + return_url: `${serverEnv().WEB_URL}/cli/complete?billing=updated`, + }, + { + idempotencyKey: `${providerIdempotencyKey}:portal`, + }, + ), + ); + return { + action: "billing_portal", + url: session.url, + requestId, + }; + }), + }); + }), + requestId, + ); + }) + .handle("deleteOrganization", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentOrganizationDelete(path.organizationId, requestId), + requestId, + ); + }) + .handle("setOrganizationDomain", ({ path, payload }) => { + const requestId = makeRequestId(); + const domain = payload.domain.trim().toLowerCase(); + if ( + !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test( + domain, + ) + ) { + return withMappedErrors( + Effect.fail(badRequest(requestId, "Custom domain is invalid")), + requestId, + ); + } + return withMappedErrors( + queueAgentOrganizationDomain({ + organizationId: path.organizationId, + kind: "set_organization_domain", + domain, + requestId, + }), + requestId, + ); + }) + .handle("removeOrganizationDomain", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentOrganizationDomain({ + organizationId: path.organizationId, + kind: "remove_organization_domain", + requestId, + }), + requestId, + ); + }) + .handle("verifyOrganizationDomain", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentOrganizationDomain({ + organizationId: path.organizationId, + kind: "verify_organization_domain", + requestId, + }), + requestId, + ); + }) + .handle("getOperation", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + getAgentOperation(path.operationId, requestId), + requestId, + ); + }) + .handle("updateOrganization", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:manage", requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + if ( + payload.name === undefined && + payload.allowedEmailDomain === undefined + ) { + return yield* badRequest( + requestId, + "At least one organization field is required", + ); + } + const name = payload.name?.trim(); + const allowedEmailDomain = + payload.allowedEmailDomain?.trim().toLowerCase() || null; + if ( + (name !== undefined && + (name.length === 0 || name.length > 255)) || + (allowedEmailDomain !== null && + (allowedEmailDomain.length > 255 || + !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(allowedEmailDomain))) + ) { + return yield* badRequest( + requestId, + "Organization fields are invalid", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_organization", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, name, allowedEmailDomain }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [organization] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where(eq(Db.organizations.id, path.organizationId)) + .limit(1) + .for("update"); + if (!organization) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.organizations) + .set({ + name, + allowedEmailDomain: + payload.allowedEmailDomain === undefined + ? undefined + : allowedEmailDomain, + updatedAt: now, + }) + .where(eq(Db.organizations.id, path.organizationId)); + return { + state: "success", + response: mutationResponse( + "organization", + path.organizationId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateOrganizationSettings", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:manage", requestId); + if (Object.values(payload).every((value) => value === undefined)) { + return yield* badRequest( + requestId, + "At least one organization setting is required", + ); + } + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + const sanitizedPayload = + payload.defaultPlaybackSpeed === undefined + ? payload + : { + ...payload, + defaultPlaybackSpeed: normalizePlaybackSpeed( + payload.defaultPlaybackSpeed, + ), + }; + return yield* runAgentMutation({ + principal, + operation: "update_organization_settings", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload: sanitizedPayload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [[organization], [account]] = await Promise.all([ + tx + .select({ settings: Db.organizations.settings }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, path.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"), + tx + .select({ + stripeSubscriptionStatus: + Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ]); + if (!organization || !account) return { state: "not_found" }; + const current = organization.settings ?? {}; + const submitted = { ...current, ...sanitizedPayload }; + const next = userIsPro(account) + ? submitted + : { + ...submitted, + disableSummary: + current.disableSummary ?? + defaultProOrganizationSettings.disableSummary, + disableChapters: + current.disableChapters ?? + defaultProOrganizationSettings.disableChapters, + disableTranscript: + current.disableTranscript ?? + defaultProOrganizationSettings.disableTranscript, + hideShareableLinkCapLogo: + current.hideShareableLinkCapLogo ?? + defaultProOrganizationSettings.hideShareableLinkCapLogo, + shareableLinkUseOrganizationIcon: + current.shareableLinkUseOrganizationIcon ?? + defaultProOrganizationSettings.shareableLinkUseOrganizationIcon, + aiGenerationLanguage: + current.aiGenerationLanguage ?? + defaultProOrganizationSettings.aiGenerationLanguage, + }; + const now = new Date(); + await tx + .update(Db.organizations) + .set({ settings: next, updatedAt: now }) + .where(eq(Db.organizations.id, path.organizationId)); + return { + state: "success", + response: mutationResponse( + "organization_settings", + path.organizationId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createOrganizationInvite", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:members", requestId); + const management = yield* AgentManagement; + const actor = yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + const email = payload.email.trim().toLowerCase(); + const role = payload.role ?? "member"; + const shouldSendEmail = payload.sendEmail ?? true; + if ( + email.length > 255 || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + ) { + return yield* badRequest(requestId, "Invite email is invalid"); + } + const idempotencyKey = yield* requestIdempotencyKey; + const invitation = yield* runAgentMutation({ + principal, + operation: "create_organization_invite", + idempotencyKey, + request: { path, email, role, sendEmail: shouldSendEmail }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + Agent.AgentOrganizationInviteResponse, + ), + execute: async (tx) => { + const [member] = await tx + .select({ id: Db.organizationMembers.id }) + .from(Db.organizationMembers) + .innerJoin( + Db.users, + eq(Db.organizationMembers.userId, Db.users.id), + ) + .where( + and( + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + sql`LOWER(${Db.users.email}) = ${email}`, + ), + ) + .limit(1); + if (member) return { state: "conflict" }; + const [existing] = await tx + .select() + .from(Db.organizationInvites) + .where( + and( + eq( + Db.organizationInvites.organizationId, + path.organizationId, + ), + sql`LOWER(TRIM(${Db.organizationInvites.invitedEmail})) = ${email}`, + ), + ) + .orderBy(Db.organizationInvites.createdAt) + .limit(1) + .for("update"); + const now = new Date(); + const id = + existing?.id ?? + deterministicAgentId( + "organization_invite", + path.organizationId, + email, + ); + if (existing) { + await tx + .update(Db.organizationInvites) + .set({ role, status: "pending", updatedAt: now }) + .where(eq(Db.organizationInvites.id, existing.id)); + } else { + await tx.insert(Db.organizationInvites).values({ + id, + organizationId: path.organizationId, + invitedEmail: email, + invitedByUserId: principal.id, + role, + createdAt: now, + updatedAt: now, + }); + } + return { + state: "success", + response: { + invite: { + id, + invitedEmail: email, + role, + status: "pending", + expiresAt: existing?.expiresAt?.toISOString() ?? null, + createdAt: + existing?.createdAt.toISOString() ?? now.toISOString(), + updatedAt: now.toISOString(), + capabilities: organizationCapabilities( + actor.role, + principal.scopes, + ), + }, + inviteUrl: `${serverEnv().WEB_URL}/invite/${id}`, + emailDelivery: "not_requested" as const, + requestId, + }, + }; + }, + }); + if (!shouldSendEmail) return invitation; + const database = yield* Database; + const [organization] = yield* database.use((db) => + db + .select({ name: Db.organizations.name }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, path.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1), + ); + if (!organization) return yield* notFound(requestId); + return yield* runAgentExternalMutation({ + principal, + operation: "send_organization_invite_email", + idempotencyKey, + request: { + organizationId: path.organizationId, + inviteId: invitation.invite.id, + email, + }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + Agent.AgentOrganizationInviteResponse, + ), + execute: (providerIdempotencyKey) => + Effect.tryPromise({ + try: async () => { + const delivery = await sendEmail({ + email, + subject: `Invitation to join ${organization.name} on Cap`, + react: OrganizationInvite({ + email, + url: invitation.inviteUrl, + organizationName: organization.name, + }), + idempotencyKey: providerIdempotencyKey, + }); + if (delivery?.error) + throw new Error(delivery.error.message); + return { + ...invitation, + emailDelivery: "accepted" as const, + }; + }, + catch: () => + temporarilyUnavailable( + requestId, + "The organization invite email could not be delivered", + ), + }), + }); + }), + requestId, + ); + }) + .handle("deleteOrganizationInvite", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:members", requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + return yield* runAgentMutation({ + principal, + operation: "delete_organization_invite", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const result = await tx + .delete(Db.organizationInvites) + .where( + and( + eq(Db.organizationInvites.id, path.inviteId), + eq( + Db.organizationInvites.organizationId, + path.organizationId, + ), + ), + ); + if (affectedRows(result) === 0) return { state: "not_found" }; + const now = new Date(); + return { + state: "success", + response: mutationResponse( + "organization_invite", + path.inviteId, + "deleted", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateOrganizationMember", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:members", requestId); + const management = yield* AgentManagement; + const actor = yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + return yield* runAgentMutation({ + principal, + operation: "update_organization_member", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, role: payload.role }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [organization] = await tx + .select({ ownerId: Db.organizations.ownerId }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, path.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + const [member] = await tx + .select({ + userId: Db.organizationMembers.userId, + role: Db.organizationMembers.role, + }) + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.id, path.memberId), + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ) + .limit(1) + .for("update"); + if (!organization || !member) return { state: "not_found" }; + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: organization.ownerId, + memberRole: member.role, + }); + if ( + !canChangeOrganizationMemberRole({ + actorRole: actor.role, + actorUserId: principal.id, + targetUserId: member.userId, + ownerId: organization.ownerId, + targetRole, + nextRole: payload.role, + }) + ) { + return { state: "forbidden" }; + } + const now = new Date(); + await tx + .update(Db.organizationMembers) + .set({ role: payload.role, updatedAt: now }) + .where(eq(Db.organizationMembers.id, path.memberId)); + return { + state: "success", + response: mutationResponse( + "organization_member", + path.memberId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateOrganizationMemberSeat", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:members", requestId); + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + return yield* runAgentMutation({ + principal, + operation: "update_organization_member_seat", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, enabled: payload.enabled }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [organization] = await tx + .select({ ownerId: Db.organizations.ownerId }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, path.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + const [member] = await tx + .select() + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.id, path.memberId), + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ) + .limit(1) + .for("update"); + if (!organization || !member) return { state: "not_found" }; + if (member.userId === organization.ownerId) { + return { state: "forbidden" }; + } + if (member.hasProSeat === payload.enabled) { + return { + state: "success", + response: mutationResponse( + "organization_member_seat", + path.memberId, + payload.enabled ? "enabled" : "disabled", + requestId, + ), + }; + } + if (payload.enabled) { + const allMembers = await tx + .select({ + id: Db.organizationMembers.id, + userId: Db.organizationMembers.userId, + hasProSeat: Db.organizationMembers.hasProSeat, + }) + .from(Db.organizationMembers) + .where( + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ) + .for("update"); + const managerIds = Array.from( + new Set([organization.ownerId, principal.id]), + ); + const managers = await tx + .select({ + id: Db.users.id, + inviteQuota: Db.users.inviteQuota, + stripeSubscriptionId: Db.users.stripeSubscriptionId, + stripeSubscriptionStatus: + Db.users.stripeSubscriptionStatus, + }) + .from(Db.users) + .where(inArray(Db.users.id, managerIds)); + const owner = managers.find( + (manager) => manager.id === organization.ownerId, + ); + const currentManager = managers.find( + (manager) => manager.id === principal.id, + ); + const seatProvider = selectProSeatProvider({ + actor: currentManager, + owner, + actorCanManageProSeats: true, + }); + if (!seatProvider) return { state: "conflict" }; + const { proSeatsRemaining } = calculateProSeats({ + inviteQuota: seatProvider.inviteQuota ?? 1, + ownerId: organization.ownerId, + ownerIsPro: hasActiveDirectSubscription(owner), + members: allMembers, + }); + if (proSeatsRemaining <= 0) return { state: "conflict" }; + await tx + .update(Db.organizationMembers) + .set({ hasProSeat: true }) + .where(eq(Db.organizationMembers.id, path.memberId)); + if (seatProvider.stripeSubscriptionId) { + await tx + .update(Db.users) + .set({ + thirdPartyStripeSubscriptionId: + seatProvider.stripeSubscriptionId, + }) + .where(eq(Db.users.id, member.userId)); + } + } else { + await tx + .update(Db.organizationMembers) + .set({ hasProSeat: false }) + .where(eq(Db.organizationMembers.id, path.memberId)); + const otherProSeats = await tx + .select({ id: Db.organizationMembers.id }) + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.userId, member.userId), + eq(Db.organizationMembers.hasProSeat, true), + ), + ) + .limit(1); + if (otherProSeats.length === 0) { + await tx + .update(Db.users) + .set({ thirdPartyStripeSubscriptionId: null }) + .where(eq(Db.users.id, member.userId)); + } else { + const [remainingOrganization] = await tx + .select({ + stripeSubscriptionId: Db.users.stripeSubscriptionId, + }) + .from(Db.organizationMembers) + .innerJoin( + Db.organizations, + eq( + Db.organizationMembers.organizationId, + Db.organizations.id, + ), + ) + .innerJoin( + Db.users, + eq(Db.organizations.ownerId, Db.users.id), + ) + .where( + and( + eq(Db.organizationMembers.userId, member.userId), + eq(Db.organizationMembers.hasProSeat, true), + ), + ) + .limit(1); + await tx + .update(Db.users) + .set({ + thirdPartyStripeSubscriptionId: + remainingOrganization?.stripeSubscriptionId ?? null, + }) + .where(eq(Db.users.id, member.userId)); + } + } + const now = new Date(); + return { + state: "success", + response: mutationResponse( + "organization_member_seat", + path.memberId, + payload.enabled ? "enabled" : "disabled", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("removeOrganizationMember", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:members", requestId); + const management = yield* AgentManagement; + const actor = yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + return yield* runAgentMutation({ + principal, + operation: "remove_organization_member", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [organization] = await tx + .select({ ownerId: Db.organizations.ownerId }) + .from(Db.organizations) + .where( + and( + eq(Db.organizations.id, path.organizationId), + isNull(Db.organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + const [member] = await tx + .select({ + userId: Db.organizationMembers.userId, + role: Db.organizationMembers.role, + email: Db.users.email, + }) + .from(Db.organizationMembers) + .innerJoin( + Db.users, + eq(Db.organizationMembers.userId, Db.users.id), + ) + .where( + and( + eq(Db.organizationMembers.id, path.memberId), + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ) + .limit(1) + .for("update"); + if (!organization || !member) return { state: "not_found" }; + const targetRole = getEffectiveOrganizationRole({ + userId: member.userId, + ownerId: organization.ownerId, + memberRole: member.role, + }); + if ( + !canRemoveOrganizationMember({ + actorRole: actor.role, + actorUserId: principal.id, + targetUserId: member.userId, + ownerId: organization.ownerId, + targetRole, + }) + ) { + return { state: "forbidden" }; + } + const organizationSpaces = await tx + .select({ id: Db.spaces.id }) + .from(Db.spaces) + .where(eq(Db.spaces.organizationId, path.organizationId)); + const spaceIds = organizationSpaces.map((space) => space.id); + if (spaceIds.length > 0) { + await tx + .delete(Db.spaceMembers) + .where( + and( + eq(Db.spaceMembers.userId, member.userId), + inArray(Db.spaceMembers.spaceId, spaceIds), + ), + ); + } + await tx + .delete(Db.organizationInvites) + .where( + and( + eq( + Db.organizationInvites.organizationId, + path.organizationId, + ), + sql`LOWER(TRIM(${Db.organizationInvites.invitedEmail})) = ${member.email.toLowerCase()}`, + ), + ); + const result = await tx + .delete(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.id, path.memberId), + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ); + if (affectedRows(result) === 0) return { state: "not_found" }; + const now = new Date(); + return { + state: "success", + response: mutationResponse( + "organization_member", + path.memberId, + "deleted", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateMe", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "profile:write", requestId); + if ( + payload.name === undefined && + payload.lastName === undefined && + payload.defaultOrganizationId === undefined + ) { + return yield* badRequest( + requestId, + "At least one profile field is required", + ); + } + const name = payload.name?.trim() || null; + const lastName = payload.lastName?.trim() || null; + if ((name?.length ?? 0) > 255 || (lastName?.length ?? 0) > 255) { + return yield* badRequest(requestId, "Profile names are too long"); + } + const response = yield* runAgentMutation({ + principal, + operation: "update_profile", + idempotencyKey: yield* requestIdempotencyKey, + request: { + name, + lastName, + defaultOrganizationId: payload.defaultOrganizationId, + }, + requestId, + decodeReplay: Schema.decodeUnknownSync(Agent.AgentMeResponse), + execute: async (tx) => { + if (payload.defaultOrganizationId) { + const [membership] = await tx + .select({ id: Db.organizationMembers.id }) + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.userId, principal.id), + eq( + Db.organizationMembers.organizationId, + payload.defaultOrganizationId, + ), + ), + ) + .limit(1); + if (!membership) return { state: "forbidden" }; + } + await tx + .update(Db.users) + .set({ + name: payload.name === undefined ? undefined : name, + lastName: + payload.lastName === undefined ? undefined : lastName, + defaultOrgId: payload.defaultOrganizationId, + }) + .where(eq(Db.users.id, principal.id)); + const [account] = await tx + .select({ + id: Db.users.id, + email: Db.users.email, + name: Db.users.name, + lastName: Db.users.lastName, + image: Db.users.image, + activeOrganizationId: Db.users.activeOrganizationId, + defaultOrganizationId: Db.users.defaultOrgId, + createdAt: Db.users.created_at, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1); + if (!account) return { state: "not_found" }; + return { + state: "success", + response: { + ...account, + image: account.image ?? null, + activeOrganizationId: account.activeOrganizationId ?? null, + defaultOrganizationId: + account.defaultOrganizationId ?? null, + createdAt: account.createdAt.toISOString(), + capabilities: profileCapabilities(principal.scopes), + requestId, + }, + }; + }, + }); + return response; + }), + requestId, + ); + }) + .handle("updateProfileImage", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "profile" }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("removeProfileImage", () => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "profile" }, + payload: null, + requestId, + }), + requestId, + ); + }) + .handle("signOutAllDevices", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "profile:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "sign_out_all_devices", + idempotencyKey: yield* requestIdempotencyKey, + request: { userId: principal.id }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [account] = await tx + .select({ id: Db.users.id }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1) + .for("update"); + if (!account) return { state: "not_found" }; + await tx + .update(Db.users) + .set({ + authSessionVersion: sql`${Db.users.authSessionVersion} + 1`, + }) + .where(eq(Db.users.id, principal.id)); + await tx + .delete(Db.sessions) + .where(eq(Db.sessions.userId, principal.id)); + await tx + .delete(Db.authApiKeys) + .where(eq(Db.authApiKeys.userId, principal.id)); + await tx + .update(Db.agentApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(Db.agentApiKeys.userId, principal.id), + isNull(Db.agentApiKeys.revokedAt), + ), + ); + return { + state: "success", + response: mutationResponse( + "account_sessions", + principal.id, + "revoked", + requestId, + new Date(), + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("openReferralPortal", () => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "profile:read", requestId); + const apiKey = serverEnv().DUB_API_KEY; + if (!apiKey) { + return yield* forbidden( + requestId, + "Cap referrals are not available on this server", + ); + } + const database = yield* Database; + const [account] = yield* database.use((db) => + db + .select({ name: Db.users.name, image: Db.users.image }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + if (!account) return yield* notFound(requestId); + return yield* runAgentExternalMutation({ + principal, + operation: "open_referral_portal", + idempotencyKey: yield* requestIdempotencyKey, + request: { userId: principal.id }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + Agent.AgentBrowserActionResponse, + ), + execute: () => + Effect.tryPromise({ + try: async () => { + const response = await fetch( + "https://api.dub.co/tokens/embed/referrals", + { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tenantId: principal.id, + partner: { + name: account.name ?? principal.email, + email: principal.email, + image: account.image ?? undefined, + tenantId: principal.id, + }, + }), + }, + ); + if (!response.ok) throw new Error("Dub request failed"); + const data: unknown = await response.json(); + if (!data || typeof data !== "object") { + throw new Error("Dub response is invalid"); + } + const token = + ("publicToken" in data && + typeof data.publicToken === "string" && + data.publicToken) || + ("token" in data && + typeof data.token === "string" && + data.token); + if (!token) throw new Error("Dub token is missing"); + return { + action: "open_referrals", + url: `https://app.dub.co/embed/referrals?token=${encodeURIComponent(token)}`, + requestId, + }; + }, + catch: () => + temporarilyUnavailable( + requestId, + "The referral portal could not be opened", + ), + }), + }); + }), + requestId, + ); + }) + .handle("createOrganization", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "organizations:manage", requestId); + const name = payload.name.trim(); + if (name.length === 0 || name.length > 255) { + return yield* badRequest( + requestId, + "Organization name is invalid", + ); + } + const organizationId = Organisation.OrganisationId.make( + deterministicAgentId( + "organization", + principal.id, + name.toLowerCase(), + ), + ); + return yield* runAgentMutation({ + principal, + operation: "create_organization", + idempotencyKey: yield* requestIdempotencyKey, + request: { name }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [existingName] = await tx + .select({ id: Db.organizations.id }) + .from(Db.organizations) + .where( + sql`LOWER(${Db.organizations.name}) = ${name.toLowerCase()}`, + ) + .limit(1); + if (existingName && existingName.id !== organizationId) { + return { state: "conflict" }; + } + await tx + .insert(Db.organizations) + .values({ + id: organizationId, + ownerId: principal.id, + name, + }) + .onDuplicateKeyUpdate({ + set: { id: sql`${Db.organizations.id}` }, + }); + const [organization] = await tx + .select({ + ownerId: Db.organizations.ownerId, + name: Db.organizations.name, + }) + .from(Db.organizations) + .where(eq(Db.organizations.id, organizationId)) + .limit(1) + .for("update"); + if ( + !organization || + organization.ownerId !== principal.id || + organization.name !== name + ) { + return { state: "conflict" }; + } + await tx + .insert(Db.organizationMembers) + .values({ + id: deterministicAgentId( + "organization_member", + organizationId, + principal.id, + ), + userId: principal.id, + organizationId, + role: "owner", + }) + .onDuplicateKeyUpdate({ + set: { id: sql`${Db.organizationMembers.id}` }, + }); + await tx + .update(Db.users) + .set({ activeOrganizationId: organizationId }) + .where(eq(Db.users.id, principal.id)); + return { + state: "success", + response: mutationResponse( + "organization", + organizationId, + "created", + requestId, + new Date(), + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateOrganizationIcon", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { + type: "organization", + organizationId: path.organizationId, + image: "icon", + }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("removeOrganizationIcon", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { + type: "organization", + organizationId: path.organizationId, + image: "icon", + }, + payload: null, + requestId, + }), + requestId, + ); + }) + .handle("updateShareableLinkIcon", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { + type: "organization", + organizationId: path.organizationId, + image: "shareableLinkIcon", + }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("removeShareableLinkIcon", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { + type: "organization", + organizationId: path.organizationId, + image: "shareableLinkIcon", + }, + payload: null, + requestId, + }), + requestId, + ); + }) + .handle("updateOrganizationS3", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + const config = yield* resolveAgentS3Config( + path.organizationId, + payload, + requestId, + ); + return yield* runAgentExternalMutation({ + principal, + operation: "update_organization_s3", + idempotencyKey: yield* requestIdempotencyKey, + request: { + path, + provider: config.provider, + endpoint: config.endpoint, + bucketName: config.bucketName, + region: config.region, + accessKeyIdHash: createHash("sha256") + .update(config.accessKeyId) + .digest("hex"), + secretAccessKeyHash: createHash("sha256") + .update(config.secretAccessKey) + .digest("hex"), + }, + requestId, + decodeReplay: decodeMutationResponse, + execute: () => + Effect.gen(function* () { + yield* testAgentS3Config(config, requestId); + const database = yield* Database; + const encrypted = yield* Effect.tryPromise(async () => { + const [ + accessKeyId, + secretAccessKey, + endpoint, + bucketName, + region, + ] = await Promise.all([ + encrypt(config.accessKeyId), + encrypt(config.secretAccessKey), + config.endpoint ? encrypt(config.endpoint) : null, + encrypt(config.bucketName), + encrypt(config.region), + ]); + return { + accessKeyId, + secretAccessKey, + endpoint, + bucketName, + region, + }; + }); + const id = S3Bucket.S3BucketId.make(nanoId()); + yield* database.use((db) => + db.transaction(async (tx) => { + await tx + .update(Db.s3Buckets) + .set({ active: false }) + .where( + eq(Db.s3Buckets.organizationId, path.organizationId), + ); + await tx.insert(Db.s3Buckets).values({ + id, + ownerId: principal.id, + organizationId: path.organizationId, + provider: config.provider, + ...encrypted, + active: true, + }); + }), + ); + return mutationResponse( + "storage_s3", + id, + "configured", + requestId, + new Date(), + ); + }), + }); + }), + requestId, + ); + }) + .handle("testOrganizationS3", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + const config = yield* resolveAgentS3Config( + path.organizationId, + payload, + requestId, + ); + return yield* runAgentExternalMutation({ + principal, + operation: "test_organization_s3", + idempotencyKey: yield* requestIdempotencyKey, + request: { + path, + endpoint: config.endpoint, + bucketName: config.bucketName, + region: config.region, + credentialHash: createHash("sha256") + .update(`${config.accessKeyId}\0${config.secretAccessKey}`) + .digest("hex"), + }, + requestId, + decodeReplay: decodeMutationResponse, + execute: () => + testAgentS3Config(config, requestId).pipe( + Effect.as( + mutationResponse( + "storage_s3", + path.organizationId, + "verified", + requestId, + new Date(), + ), + ), + ), + }); + }), + requestId, + ); + }) + .handle("removeOrganizationS3", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + return yield* runAgentMutation({ + principal, + operation: "remove_organization_s3", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [bucket] = await tx + .select({ id: Db.s3Buckets.id }) + .from(Db.s3Buckets) + .where( + and( + eq(Db.s3Buckets.organizationId, path.organizationId), + eq(Db.s3Buckets.active, true), + ), + ) + .orderBy(desc(Db.s3Buckets.updatedAt)) + .limit(1) + .for("update"); + if (!bucket) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.s3Buckets) + .set({ active: false, updatedAt: now }) + .where(eq(Db.s3Buckets.id, bucket.id)); + return { + state: "success", + response: mutationResponse( + "storage_s3", + bucket.id, + "disconnected", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("connectOrganizationGoogleDrive", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + return yield* runAgentExternalMutation({ + principal, + operation: "connect_organization_google_drive", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeBrowserActionResponse, + execute: () => + Effect.try({ + try: () => ({ + action: "google_drive_connect", + url: getGoogleDriveAuthUrl({ + state: createAgentGoogleDriveState( + principal.id, + path.organizationId, + ), + }), + requestId, + }), + catch: () => + temporarilyUnavailable( + requestId, + "Google Drive authorization is unavailable", + ), + }), + }); + }), + requestId, + ); + }) + .handle("disconnectOrganizationGoogleDrive", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + return yield* runAgentMutation({ + principal, + operation: "disconnect_organization_google_drive", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [drive] = await tx + .select({ id: Db.storageIntegrations.id }) + .from(Db.storageIntegrations) + .where( + and( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + eq(Db.storageIntegrations.provider, googleDriveProvider), + ), + ) + .orderBy(desc(Db.storageIntegrations.updatedAt)) + .limit(1) + .for("update"); + if (!drive) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.storageIntegrations) + .set({ + active: false, + status: "disconnected", + googleDriveAccessToken: null, + googleDriveAccessTokenExpiresAt: null, + googleDriveTokenRefreshLeaseId: null, + googleDriveTokenRefreshLeaseExpiresAt: null, + googleDriveStorageQuotaCache: null, + updatedAt: now, + }) + .where( + and( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + eq(Db.storageIntegrations.provider, googleDriveProvider), + ), + ); + return { + state: "success", + response: mutationResponse( + "storage_google_drive", + drive.id, + "disconnected", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateOrganizationStorageProvider", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + return yield* runAgentMutation({ + principal, + operation: "update_organization_storage_provider", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, provider: payload.provider }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + if (payload.provider === "s3") { + const [bucket] = await tx + .select({ id: Db.s3Buckets.id }) + .from(Db.s3Buckets) + .where( + and( + eq(Db.s3Buckets.organizationId, path.organizationId), + eq(Db.s3Buckets.active, true), + ), + ) + .orderBy(desc(Db.s3Buckets.updatedAt)) + .limit(1); + if (!bucket) return { state: "not_found" }; + await tx + .update(Db.storageIntegrations) + .set({ active: false }) + .where( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + ); + return { + state: "success", + response: mutationResponse( + "storage_provider", + bucket.id, + "activated", + requestId, + new Date(), + ), + }; + } + const [drive] = await tx + .select({ id: Db.storageIntegrations.id }) + .from(Db.storageIntegrations) + .where( + and( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + eq(Db.storageIntegrations.provider, googleDriveProvider), + eq(Db.storageIntegrations.status, "active"), + ), + ) + .orderBy(desc(Db.storageIntegrations.updatedAt)) + .limit(1) + .for("update"); + if (!drive) return { state: "not_found" }; + await tx + .update(Db.storageIntegrations) + .set({ active: false }) + .where( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + ); + await tx + .update(Db.storageIntegrations) + .set({ active: true }) + .where(eq(Db.storageIntegrations.id, drive.id)); + return { + state: "success", + response: mutationResponse( + "storage_provider", + drive.id, + "activated", + requestId, + new Date(), + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("setOrganizationGoogleDriveLocation", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireAgentStorageManager( + principal, + path.organizationId, + "integrations:write", + requestId, + ); + const folderId = payload.folderId.trim(); + if (folderId.length === 0 || folderId.length > 255) { + return yield* badRequest( + requestId, + "Google Drive folder ID is invalid", + ); + } + return yield* runAgentExternalMutation({ + principal, + operation: "set_organization_google_drive_location", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, ...payload, folderId }, + requestId, + decodeReplay: decodeMutationResponse, + execute: (providerIdempotencyKey) => + Effect.gen(function* () { + const drive = yield* getAgentOrganizationDrive( + path.organizationId, + ); + if (!drive || drive.status !== "active") { + return yield* notReady( + requestId, + "Google Drive is not connected", + ); + } + const config = yield* parseAgentDriveConfig( + drive.encryptedConfig, + ); + const location = + folderId === "root" + ? { + id: "root", + name: "My Drive", + driveId: null, + driveName: null, + } + : yield* getGoogleDriveFolderLocation(config, folderId); + const nextConfig: GoogleDriveIntegrationConfig = { + ...config, + folderId: location.id, + folderName: payload.folderName ?? location.name, + driveId: payload.driveId ?? location.driveId ?? null, + driveName: payload.driveName ?? location.driveName ?? null, + folderLayout: "userVideo", + }; + const email = yield* getGoogleDriveUserEmail(nextConfig); + const encryptedConfig = yield* Effect.tryPromise(() => + encrypt( + JSON.stringify({ + ...nextConfig, + email: email ?? undefined, + }), + ), + ); + const displayName = email + ? `Google Drive (${email})` + : "Google Drive"; + const database = yield* Database; + const object = yield* database.use((db) => + db + .select({ id: Db.storageObjects.id }) + .from(Db.storageObjects) + .where(eq(Db.storageObjects.integrationId, drive.id)) + .limit(1), + ); + const video = yield* database.use((db) => + db + .select({ id: Db.videos.id }) + .from(Db.videos) + .where(eq(Db.videos.storageIntegrationId, drive.id)) + .limit(1), + ); + const hasStoredData = object.length > 0 || video.length > 0; + const nextId = hasStoredData + ? StorageDomain.StorageIntegrationId.make( + deterministicAgentId( + "google_drive_location", + drive.id, + providerIdempotencyKey, + ), + ) + : drive.id; + yield* database.use((db) => + db.transaction(async (tx) => { + if (hasStoredData) { + if (drive.active) { + await tx + .update(Db.storageIntegrations) + .set({ active: false }) + .where( + eq( + Db.storageIntegrations.organizationId, + path.organizationId, + ), + ); + } + await tx + .insert(Db.storageIntegrations) + .values({ + id: nextId, + ownerId: principal.id, + organizationId: path.organizationId, + provider: googleDriveProvider, + displayName, + status: "active", + active: drive.active, + encryptedConfig, + }) + .onDuplicateKeyUpdate({ + set: { + displayName, + status: "active", + active: drive.active, + encryptedConfig, + }, + }); + return; + } + await tx + .update(Db.storageIntegrations) + .set({ + displayName, + status: "active", + encryptedConfig, + googleDriveStorageQuotaCache: null, + }) + .where(eq(Db.storageIntegrations.id, drive.id)); + }), + ); + return mutationResponse( + "storage_google_drive", + nextId, + "location_updated", + requestId, + new Date(), + ); + }), + }); + }), + requestId, + ); + }) + .handle("updateNotificationPreferences", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "notifications:write", requestId); + if (Object.values(payload).every((value) => value === undefined)) { + return yield* badRequest( + requestId, + "At least one preference is required", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_notification_preferences", + idempotencyKey: yield* requestIdempotencyKey, + request: payload, + requestId, + decodeReplay: Schema.decodeUnknownSync( + Agent.AgentNotificationPreferencesResponse, + ), + execute: async (tx) => { + const [user] = await tx + .select({ preferences: Db.users.preferences }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1) + .for("update"); + if (!user) return { state: "not_found" }; + const current = user.preferences?.notifications; + const notifications = { + pauseComments: + payload.pauseComments ?? current?.pauseComments ?? false, + pauseReplies: + payload.pauseReplies ?? current?.pauseReplies ?? false, + pauseViews: + payload.pauseViews ?? current?.pauseViews ?? false, + pauseReactions: + payload.pauseReactions ?? current?.pauseReactions ?? false, + pauseAnonViews: + payload.pauseAnonymousViews ?? + current?.pauseAnonViews ?? + false, + }; + await tx + .update(Db.users) + .set({ + preferences: { + ...(user.preferences ?? {}), + notifications, + }, + }) + .where(eq(Db.users.id, principal.id)); + return { + state: "success", + response: { + pauseComments: notifications.pauseComments, + pauseReplies: notifications.pauseReplies, + pauseViews: notifications.pauseViews, + pauseReactions: notifications.pauseReactions, + pauseAnonymousViews: notifications.pauseAnonViews, + requestId, + }, + }; + }, + }); + }), + requestId, + ); + }) + .handle("markNotificationsRead", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "notifications:write", requestId); + const ids = [...new Set(payload.ids ?? [])]; + if ( + (payload.all ? 1 : 0) + (ids.length > 0 ? 1 : 0) !== 1 || + ids.length > 100 + ) { + return yield* badRequest( + requestId, + "Choose all or between one and 100 notification IDs", + ); + } + return yield* runAgentMutation({ + principal, + operation: "mark_notifications_read", + idempotencyKey: yield* requestIdempotencyKey, + request: { all: payload.all === true, ids }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + await tx + .update(Db.notifications) + .set({ readAt: new Date() }) + .where( + and( + eq(Db.notifications.recipientId, principal.id), + payload.all + ? undefined + : inArray(Db.notifications.id, ids), + ), + ); + return { + state: "success", + response: mutationResponse( + "notifications", + payload.all ? "all" : ids.join(","), + "marked_read", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createUpload", ({ payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:upload", requestId); + const contentLength = payload.contentLength ?? 0; + if ( + !payload.fileName.trim() || + payload.fileName.length > 255 || + contentLength < 0 || + (payload.durationSeconds !== undefined && + payload.durationSeconds < 0) || + (payload.width !== undefined && payload.width <= 0) || + (payload.height !== undefined && payload.height <= 0) || + (payload.fps !== undefined && payload.fps <= 0) + ) { + return yield* badRequest(requestId, "Upload metadata is invalid"); + } + const organizationId = + payload.organizationId ?? principal.activeOrganizationId; + const management = yield* AgentManagement; + yield* management.getMembership(principal.id, organizationId); + if (payload.folderId) { + const access = yield* management.getFolderAccess( + principal.id, + payload.folderId, + ); + if ( + access.folder.organizationId !== organizationId || + access.folder.spaceId !== null || + access.folder.createdById !== principal.id + ) { + return yield* forbidden(requestId); + } + } + const storage = yield* Storage; + const writable = yield* storage.getWritableAccessForUser( + principal.id, + organizationId, + ); + const videoId = Video.VideoId.make(nanoId()); + const rawFileKey = `${principal.id}/${videoId}/raw-upload.mp4`; + const fileTitle = payload.fileName.replace(/\.[^/.]+$/, "").trim(); + const title = payload.title?.trim() || fileTitle || "Cap Upload"; + if (title.length > 255) { + return yield* badRequest(requestId, "Upload title is too long"); + } + const state = yield* runAgentMutation({ + principal, + operation: "create_upload", + idempotencyKey: yield* requestIdempotencyKey, + request: payload, + requestId, + decodeReplay: Schema.decodeUnknownSync(AgentUploadMutationState), + execute: async (tx) => { + const now = new Date(); + await tx.insert(Db.videos).values({ + id: videoId, + name: title, + ownerId: principal.id, + orgId: organizationId, + source: { type: "webMP4" }, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull( + writable.storageIntegrationId, + ), + folderId: payload.folderId ?? null, + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + duration: payload.durationSeconds, + width: payload.width, + height: payload.height, + fps: payload.fps, + metadata: { sourceName: payload.fileName }, + createdAt: now, + updatedAt: now, + }); + await tx.insert(Db.videoUploads).values({ + videoId, + total: contentLength, + mode: "singlepart", + }); + return { + state: "success", + response: { + id: videoId, + organizationId, + rawFileKey, + shareUrl: `${serverEnv().WEB_URL}/s/${videoId}`, + requestId, + }, + }; + }, + }); + const upload = yield* writable.access.createUploadTarget( + state.rawFileKey, + { + contentType: "video/mp4", + method: "put", + fields: { + "Content-Type": "video/mp4", + "x-amz-meta-userid": principal.id, + "x-amz-meta-source": "cap-agent-cli", + }, + }, + ); + return { + id: state.id, + shareUrl: state.shareUrl, + rawFileKey: state.rawFileKey, + upload, + requestId: state.requestId, + }; + }), + requestId, + ); + }) + .handle("completeUpload", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:upload", requestId); + const expectedRawFileKey = `${principal.id}/${path.id}/raw-upload.mp4`; + if ( + payload.rawFileKey !== expectedRawFileKey || + (payload.contentLength !== undefined && payload.contentLength < 0) + ) { + return yield* badRequest( + requestId, + "Upload completion is invalid", + ); + } + const state = yield* runAgentMutation({ + principal, + operation: "complete_upload", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + AgentUploadCompletionState, + ), + execute: async (tx) => { + const [video] = await tx + .select({ + id: Db.videos.id, + ownerId: Db.videos.ownerId, + bucketId: Db.videos.bucket, + }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const [upload] = await tx + .select({ videoId: Db.videoUploads.videoId }) + .from(Db.videoUploads) + .where(eq(Db.videoUploads.videoId, path.id)) + .limit(1) + .for("update"); + if (!upload) return { state: "not_found" }; + if (payload.contentLength !== undefined) { + await tx + .update(Db.videoUploads) + .set({ + uploaded: payload.contentLength, + total: payload.contentLength, + updatedAt: new Date(), + }) + .where(eq(Db.videoUploads.videoId, path.id)); + } + return { + state: "success", + response: { + id: video.id, + ownerId: video.ownerId, + bucketId: video.bucketId, + rawFileKey: expectedRawFileKey, + requestId, + }, + }; + }, + }); + const processing = yield* Effect.tryPromise(() => + startVideoProcessingWorkflow({ + videoId: state.id, + userId: state.ownerId, + rawFileKey: state.rawFileKey, + bucketId: state.bucketId, + processingMessage: "Starting video processing...", + startFailureMessage: + "Video uploaded, but processing could not start.", + mode: "singlepart", + }), + ); + return { + id: state.id, + processing, + requestId: state.requestId, + }; + }), + requestId, + ); + }) + .handle("importLoomCap", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentLoomImport({ + organizationId: path.organizationId, + payload, + requestId, + }), + requestId, + ); + }) + .handle("processCap", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:process", requestId); + const { row, rules } = yield* getViewableCap(path.id); + if (row.ownerId !== principal.id) { + return yield* forbidden(requestId); + } + const includesTranscript = payload.target !== "ai"; + const includesAi = payload.target !== "transcript"; + if (includesTranscript && rules.settings.disableTranscript) { + return yield* contentDisabled(requestId, "Transcript"); + } + let aiEnabled = false; + if (includesAi) { + const database = yield* Database; + const [owner] = yield* database.use((db) => + db + .select({ + email: Db.users.email, + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: + Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, principal.id)) + .limit(1), + ); + aiEnabled = owner + ? yield* Effect.promise(() => isAiGenerationEnabled(owner)) + : false; + if (!aiEnabled) { + return yield* forbidden( + requestId, + "AI generation is not available for this account", + ); + } + } + const state = yield* runAgentMutation({ + principal, + operation: "process_cap", + idempotencyKey: yield* requestIdempotencyKey, + request: { id: path.id, payload }, + requestId, + decodeReplay: Schema.decodeUnknownSync(AgentProcessMutationState), + execute: async (tx) => { + const [video] = await tx + .select({ + id: Db.videos.id, + ownerId: Db.videos.ownerId, + transcriptionStatus: Db.videos.transcriptionStatus, + metadata: Db.videos.metadata, + }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const metadata = normalizeAgentMetadata(video.metadata); + const aiStatus = metadata.aiGenerationStatus ?? null; + if ( + includesAi && + (aiStatus === "ERROR" || aiStatus === "SKIPPED") && + payload.retry !== true + ) { + return { state: "conflict" }; + } + if ( + payload.target === "ai" && + video.transcriptionStatus !== "COMPLETE" + ) { + return { state: "conflict" }; + } + if ( + payload.target === "all" && + video.transcriptionStatus === "PROCESSING" && + aiStatus !== "QUEUED" && + aiStatus !== "PROCESSING" && + aiStatus !== "COMPLETE" + ) { + return { state: "conflict" }; + } + let transcriptionStatus = video.transcriptionStatus; + if (includesTranscript && transcriptionStatus === "ERROR") { + if (payload.retry !== true) return { state: "conflict" }; + await tx + .update(Db.videos) + .set({ transcriptionStatus: null, updatedAt: new Date() }) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.transcriptionStatus, "ERROR"), + ), + ); + transcriptionStatus = null; + } + if ( + includesTranscript && + (transcriptionStatus === "SKIPPED" || + transcriptionStatus === "NO_AUDIO") + ) { + return { state: "conflict" }; + } + return { + state: "success", + response: { + id: video.id, + ownerId: video.ownerId, + target: payload.target, + transcriptionStatus, + aiGenerationStatus: aiStatus, + aiEnabled, + requestId, + }, + }; + }, + }); + if ( + state.target !== "ai" && + state.transcriptionStatus !== "COMPLETE" + ) { + const result = yield* Effect.tryPromise(() => + transcribeVideo( + state.id, + state.ownerId, + state.target === "all" && state.aiEnabled, + ), + ); + if (!result.success) { + return yield* temporarilyUnavailable(requestId, result.message); + } + } else if (state.target !== "transcript") { + const result = yield* Effect.tryPromise(() => + startAiGeneration(state.id, state.ownerId), + ); + if (!result.success) { + return yield* temporarilyUnavailable(requestId, result.message); + } + } + const status = getStatus(yield* getStatusRow(path.id)); + return { + id: path.id, + requested: payload.target, + transcript: status.transcript, + ai: status.ai, + requestId: state.requestId, + }; + }), + requestId, + ); + }) + .handle("replaceTranscript", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:write", requestId); + if (!/^[a-f0-9]{64}$/.test(payload.expectedRevision)) { + return yield* badRequest( + requestId, + "expectedRevision is invalid", + ); + } + const normalized = normalizeTranscriptCues(payload.cues); + if (!normalized) { + return yield* badRequest( + requestId, + "Transcript cues are invalid", + ); + } + const { video, cap } = yield* getViewableCap(path.id); + if (!cap.capabilities.editTranscript.allowed) { + return yield* capabilityFailure( + requestId, + cap.capabilities.editTranscript, + ); + } + const storage = yield* Storage; + const [bucket] = yield* storage.getAccessForVideo(video); + const transcriptKey = `${video.ownerId}/${path.id}/transcription.vtt`; + const desiredRevision = agentTranscriptRevision(normalized.vtt); + const response = yield* runAgentMutation({ + principal, + operation: "replace_transcript", + idempotencyKey: yield* requestIdempotencyKey, + request: { + id: path.id, + expectedRevision: payload.expectedRevision, + desiredRevision, + }, + requestId, + decodeReplay: Schema.decodeUnknownSync( + Agent.AgentTranscriptUpdateResponse, + ), + execute: async (tx) => { + const [ownedVideo] = await tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!ownedVideo) return { state: "not_found" }; + const object = await Effect.runPromise( + bucket.getObject(transcriptKey), + ); + if (Option.isNone(object)) return { state: "not_found" }; + const currentRevision = agentTranscriptRevision(object.value); + if ( + currentRevision !== payload.expectedRevision && + currentRevision !== desiredRevision + ) { + return { state: "conflict" }; + } + if (currentRevision !== desiredRevision) { + await Effect.runPromise( + bucket.putObject(transcriptKey, normalized.vtt, { + contentType: "text/vtt", + }), + ); + } + const now = new Date(); + await tx + .update(Db.videos) + .set({ updatedAt: now }) + .where(eq(Db.videos.id, path.id)); + return { + state: "success", + response: { + id: path.id, + revision: desiredRevision, + cueCount: normalized.cues.length, + updatedAt: now.toISOString(), + requestId, + }, + }; + }, + }); + yield* Effect.sync(() => revalidatePath(`/s/${path.id}`)); + return response; + }), + requestId, + ); + }) + .handle("updateCapPassword", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + yield* requireUserConfirmedRequest(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:write", requestId); + const request = yield* HttpServerRequest.HttpServerRequest; + const contentType = + request.headers["content-type"]?.toLowerCase() ?? ""; + if (!contentType.startsWith("text/plain")) { + return yield* badRequest( + requestId, + "Password input must be text/plain", + ); + } + const value = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(1_024)), + Effect.catchTag("RequestError", () => + Effect.fail(badRequest(requestId, "Password input is invalid")), + ), + ); + const password = value.trim(); + if (Buffer.byteLength(password, "utf8") > 512) { + return yield* badRequest( + requestId, + "Password input is too large", + ); + } + const fingerprint = createHash("sha256") + .update(password, "utf8") + .digest("hex"); + const nextPassword = password + ? yield* Effect.tryPromise(() => hashPassword(password)) + : null; + const response = yield* runAgentMutation({ + principal, + operation: "update_cap_password", + idempotencyKey: yield* requestIdempotencyKey, + request: { id: path.id, fingerprint }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.videos) + .set({ password: nextPassword, updatedAt: now }) + .where(eq(Db.videos.id, path.id)); + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + password ? "password_set" : "password_cleared", + requestId, + now, + ), + }; + }, + }); + yield* Effect.sync(() => revalidatePath(`/s/${path.id}`)); + return response; + }), + requestId, + ); + }) + .handle("duplicateCap", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentCapOperation({ + videoId: path.id, + kind: "duplicate_cap", + scope: "caps:write", + requestId, + }), + requestId, + ); + }) + .handle("deleteCap", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + queueAgentCapOperation({ + videoId: path.id, + kind: "delete_cap", + scope: "caps:delete", + requestId, + }), + requestId, + ); + }) + .handle("updateCapSettings", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:write", requestId); + if (!hasViewerSettingsUpdate(payload)) { + return yield* badRequest( + requestId, + "At least one setting is required", + ); + } + if ( + payload.defaultPlaybackSpeed !== undefined && + payload.defaultPlaybackSpeed !== null && + (payload.defaultPlaybackSpeed < 0.25 || + payload.defaultPlaybackSpeed > 4) + ) { + return yield* badRequest( + requestId, + "defaultPlaybackSpeed must be between 0.25 and 4", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_cap_settings", + idempotencyKey: yield* requestIdempotencyKey, + request: { id: path.id, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ settings: Db.videos.settings }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.videos) + .set({ + settings: mergeViewerSettings(video.settings, payload), + updatedAt: now, + }) + .where(eq(Db.videos.id, path.id)); + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "settings_updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateCapDate", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:write", requestId); + const createdAt = parseAgentDate(payload.createdAt); + if (!createdAt || createdAt.getTime() > Date.now() + 60_000) { + return yield* badRequest( + requestId, + "createdAt must be a valid past UTC timestamp", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_cap_date", + idempotencyKey: yield* requestIdempotencyKey, + request: { id: path.id, createdAt: createdAt.toISOString() }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id, metadata: Db.videos.metadata }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.videos) + .set({ + metadata: { + ...(video.metadata ?? {}), + customCreatedAt: createdAt.toISOString(), + }, + updatedAt: now, + }) + .where(eq(Db.videos.id, path.id)); + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "date_updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("deleteFeedback", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "caps:comment", requestId); + return yield* runAgentMutation({ + principal, + operation: "delete_feedback", + idempotencyKey: yield* requestIdempotencyKey, + request: { videoId: path.id, commentId: path.commentId }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [comment] = await tx + .select({ id: Db.comments.id }) + .from(Db.comments) + .where( + and( + eq(Db.comments.id, path.commentId), + eq(Db.comments.videoId, path.id), + eq(Db.comments.authorId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!comment) return { state: "not_found" }; + await tx + .delete(Db.comments) + .where( + and( + eq(Db.comments.id, path.commentId), + eq(Db.comments.videoId, path.id), + eq(Db.comments.authorId, principal.id), + ), + ); + return { + state: "success", + response: mutationResponse( + "feedback", + path.commentId, + "deleted", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("moveCap", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + if (payload.container === "space" && !payload.spaceId) { + return yield* badRequest( + requestId, + "spaceId is required for a space move", + ); + } + if (payload.container !== "space" && payload.spaceId) { + return yield* badRequest( + requestId, + "spaceId is only valid for a space move", + ); + } + const targetSpaceId = + payload.container === "organization" + ? payload.organizationId + : payload.spaceId; + return yield* runAgentMutation({ + principal, + operation: "move_cap", + idempotencyKey: yield* requestIdempotencyKey, + request: { id: path.id, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id, orgId: Db.videos.orgId }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1) + .for("update"); + if (!video) return { state: "not_found" }; + if (payload.folderId) { + const [folder] = await tx + .select({ id: Db.folders.id }) + .from(Db.folders) + .where( + and( + eq(Db.folders.id, payload.folderId), + eq(Db.folders.organizationId, payload.organizationId), + payload.container === "personal" + ? and( + isNull(Db.folders.spaceId), + eq(Db.folders.createdById, principal.id), + ) + : eq( + Db.folders.spaceId, + payload.container === "organization" + ? payload.organizationId + : (targetSpaceId ?? payload.organizationId), + ), + ), + ) + .limit(1); + if (!folder) return { state: "forbidden" }; + } + if (payload.container === "personal") { + if (video.orgId !== payload.organizationId) { + return { state: "forbidden" }; + } + await tx + .update(Db.videos) + .set({ folderId: payload.folderId, updatedAt: new Date() }) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ); + } else if (payload.container === "organization") { + const [share] = await tx + .select({ id: Db.sharedVideos.id }) + .from(Db.sharedVideos) + .where( + and( + eq(Db.sharedVideos.videoId, path.id), + eq( + Db.sharedVideos.organizationId, + payload.organizationId, + ), + ), + ) + .limit(1) + .for("update"); + if (!share) return { state: "not_found" }; + await tx + .update(Db.sharedVideos) + .set({ folderId: payload.folderId }) + .where(eq(Db.sharedVideos.id, share.id)); + } else { + const [share] = await tx + .select({ id: Db.spaceVideos.id }) + .from(Db.spaceVideos) + .innerJoin( + Db.spaces, + eq(Db.spaceVideos.spaceId, Db.spaces.id), + ) + .where( + and( + eq(Db.spaceVideos.videoId, path.id), + eq( + Db.spaceVideos.spaceId, + targetSpaceId ?? payload.organizationId, + ), + eq(Db.spaces.organizationId, payload.organizationId), + ), + ) + .limit(1) + .for("update"); + if (!share) return { state: "not_found" }; + await tx + .update(Db.spaceVideos) + .set({ folderId: payload.folderId }) + .where(eq(Db.spaceVideos.id, share.id)); + } + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "moved", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("shareCapWithOrganization", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "share_cap_organization", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [[video], [membership]] = await Promise.all([ + tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1), + tx + .select({ id: Db.organizationMembers.id }) + .from(Db.organizationMembers) + .where( + and( + eq(Db.organizationMembers.userId, principal.id), + eq( + Db.organizationMembers.organizationId, + path.organizationId, + ), + ), + ) + .limit(1), + ]); + if (!video) return { state: "not_found" }; + if (!membership) return { state: "forbidden" }; + if (payload.folderId) { + const [folder] = await tx + .select({ id: Db.folders.id }) + .from(Db.folders) + .where( + and( + eq(Db.folders.id, payload.folderId), + eq(Db.folders.organizationId, path.organizationId), + eq(Db.folders.spaceId, path.organizationId), + ), + ) + .limit(1); + if (!folder) return { state: "forbidden" }; + } + const [existing] = await tx + .select({ id: Db.sharedVideos.id }) + .from(Db.sharedVideos) + .where( + and( + eq(Db.sharedVideos.videoId, path.id), + eq(Db.sharedVideos.organizationId, path.organizationId), + ), + ) + .limit(1) + .for("update"); + if (existing) { + await tx + .update(Db.sharedVideos) + .set({ folderId: payload.folderId ?? null }) + .where(eq(Db.sharedVideos.id, existing.id)); + } else { + await tx.insert(Db.sharedVideos).values({ + id: deterministicAgentId( + "organization_share", + path.id, + path.organizationId, + ), + videoId: path.id, + organizationId: path.organizationId, + folderId: payload.folderId ?? null, + sharedByUserId: principal.id, + }); + } + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "organization_shared", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("removeCapOrganizationShare", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "remove_cap_organization_share", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1); + if (!video) return { state: "not_found" }; + await tx + .delete(Db.sharedVideos) + .where( + and( + eq(Db.sharedVideos.videoId, path.id), + eq(Db.sharedVideos.organizationId, path.organizationId), + ), + ); + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "organization_unshared", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("shareCapWithSpace", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + yield* management.getSpaceAccess(principal.id, path.spaceId); + return yield* runAgentMutation({ + principal, + operation: "share_cap_space", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1); + if (!video) return { state: "not_found" }; + if (payload.folderId) { + const [folder] = await tx + .select({ id: Db.folders.id }) + .from(Db.folders) + .where( + and( + eq(Db.folders.id, payload.folderId), + eq(Db.folders.spaceId, path.spaceId), + ), + ) + .limit(1); + if (!folder) return { state: "forbidden" }; + } + const [existing] = await tx + .select({ id: Db.spaceVideos.id }) + .from(Db.spaceVideos) + .where( + and( + eq(Db.spaceVideos.videoId, path.id), + eq(Db.spaceVideos.spaceId, path.spaceId), + ), + ) + .limit(1) + .for("update"); + if (existing) { + await tx + .update(Db.spaceVideos) + .set({ folderId: payload.folderId ?? null }) + .where(eq(Db.spaceVideos.id, existing.id)); + } else { + await tx.insert(Db.spaceVideos).values({ + id: deterministicAgentId( + "space_share", + path.id, + path.spaceId, + ), + spaceId: path.spaceId, + videoId: path.id, + folderId: payload.folderId ?? null, + addedById: principal.id, + }); + } + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "space_shared", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("removeCapSpaceShare", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + return yield* runAgentMutation({ + principal, + operation: "remove_cap_space_share", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [video] = await tx + .select({ id: Db.videos.id }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, principal.id), + ), + ) + .limit(1); + if (!video) return { state: "not_found" }; + await tx + .delete(Db.spaceVideos) + .where( + and( + eq(Db.spaceVideos.videoId, path.id), + eq(Db.spaceVideos.spaceId, path.spaceId), + ), + ); + return { + state: "success", + response: mutationResponse( + "cap", + path.id, + "space_unshared", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createFolder", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const name = payload.name.trim(); + if (!name || name.length > 255) { + return yield* badRequest(requestId, "Folder name is invalid"); + } + const management = yield* AgentManagement; + const membership = yield* management.getMembership( + principal.id, + path.organizationId, + ); + if ( + payload.spaceId === path.organizationId && + membership.role === "member" + ) { + return yield* forbidden(requestId); + } + if (payload.spaceId && payload.spaceId !== path.organizationId) { + const access = yield* management.getSpaceAccess( + principal.id, + payload.spaceId, + ); + if ( + !access.canManage || + access.organizationId !== path.organizationId + ) { + return yield* forbidden(requestId); + } + } + if (payload.parentId) { + const parent = yield* management.getFolderAccess( + principal.id, + payload.parentId, + ); + if ( + !parent.canManage || + parent.folder.organizationId !== path.organizationId || + parent.folder.spaceId !== (payload.spaceId ?? null) + ) { + return yield* forbidden(requestId); + } + } + if (payload.public) { + const organization = normalizeOrganization( + yield* management.getOrganization( + principal.id, + path.organizationId, + ), + principal.scopes, + ); + if (organization.billing.plan !== "pro") { + return yield* forbidden( + requestId, + "Cap Pro is required for public collections", + ); + } + } + const folderId = Folder.FolderId.make(nanoId()); + return yield* runAgentMutation({ + principal, + operation: "create_folder", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload: { ...payload, name }, folderId }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const now = new Date(); + await tx.insert(Db.folders).values({ + id: folderId, + name, + color: payload.color ?? "normal", + public: payload.public ?? false, + organizationId: path.organizationId, + createdById: principal.id, + parentId: payload.parentId ?? null, + spaceId: payload.spaceId ?? null, + createdAt: now, + updatedAt: now, + }); + return { + state: "success", + response: mutationResponse( + "folder", + folderId, + "created", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateFolder", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + if (Object.values(payload).every((value) => value === undefined)) { + return yield* badRequest( + requestId, + "At least one folder field is required", + ); + } + const name = payload.name?.trim(); + if (name !== undefined && (!name || name.length > 255)) { + return yield* badRequest(requestId, "Folder name is invalid"); + } + if ( + payload.settings !== undefined && + (!payload.settings || + typeof payload.settings !== "object" || + Array.isArray(payload.settings)) + ) { + return yield* badRequest( + requestId, + "Folder settings must be an object", + ); + } + const management = yield* AgentManagement; + const access = yield* management.getFolderAccess( + principal.id, + path.folderId, + ); + if (!access.canManage) return yield* forbidden(requestId); + if (payload.public) { + const organization = normalizeOrganization( + yield* management.getOrganization( + principal.id, + access.folder.organizationId, + ), + principal.scopes, + ); + if (organization.billing.plan !== "pro") { + return yield* forbidden( + requestId, + "Cap Pro is required for public collections", + ); + } + } + if (payload.parentId) { + const parent = yield* management.getFolderAccess( + principal.id, + payload.parentId, + ); + if ( + !parent.canManage || + parent.folder.organizationId !== access.folder.organizationId || + parent.folder.spaceId !== access.folder.spaceId + ) { + return yield* forbidden(requestId); + } + } + return yield* runAgentMutation({ + principal, + operation: "update_folder", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload: { ...payload, name } }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [folder] = await tx + .select({ parentId: Db.folders.parentId }) + .from(Db.folders) + .where(eq(Db.folders.id, path.folderId)) + .limit(1) + .for("update"); + if (!folder) return { state: "not_found" }; + if (payload.parentId === path.folderId) + return { state: "conflict" }; + let parentId = payload.parentId ?? null; + for (let depth = 0; parentId && depth < 100; depth += 1) { + if (parentId === path.folderId) return { state: "conflict" }; + const [parent] = await tx + .select({ parentId: Db.folders.parentId }) + .from(Db.folders) + .where(eq(Db.folders.id, parentId)) + .limit(1); + if (!parent) return { state: "not_found" }; + parentId = parent.parentId; + } + if (parentId) return { state: "conflict" }; + const now = new Date(); + await tx + .update(Db.folders) + .set({ + name, + color: payload.color, + parentId: payload.parentId, + public: payload.public, + settings: + payload.settings as typeof Db.folders.$inferInsert.settings, + updatedAt: now, + }) + .where(eq(Db.folders.id, path.folderId)); + return { + state: "success", + response: mutationResponse( + "folder", + path.folderId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateFolderPublicPage", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentCollectionPublicPage({ + target: { type: "folder", folderId: path.folderId }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("updateFolderLogo", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "folder", folderId: path.folderId }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("removeFolderLogo", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "folder", folderId: path.folderId }, + payload: null, + requestId, + }), + requestId, + ); + }) + .handle("deleteFolder", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + const access = yield* management.getFolderAccess( + principal.id, + path.folderId, + ); + if (!access.canManage) return yield* forbidden(requestId); + return yield* runAgentMutation({ + principal, + operation: "delete_folder", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [folder] = await tx + .select({ + id: Db.folders.id, + parentId: Db.folders.parentId, + spaceId: Db.folders.spaceId, + organizationId: Db.folders.organizationId, + }) + .from(Db.folders) + .where(eq(Db.folders.id, path.folderId)) + .limit(1) + .for("update"); + if (!folder) return { state: "not_found" }; + const folderIds = [folder.id]; + for (let offset = 0; offset < folderIds.length; offset += 100) { + if (folderIds.length > 1_000) return { state: "conflict" }; + const parents = folderIds.slice(offset, offset + 100); + const children = await tx + .select({ id: Db.folders.id }) + .from(Db.folders) + .where(inArray(Db.folders.parentId, parents)); + folderIds.push( + ...children + .map((child) => child.id) + .filter((id) => !folderIds.includes(id)), + ); + } + if (folder.spaceId === null) { + await tx + .update(Db.videos) + .set({ folderId: folder.parentId }) + .where( + and( + inArray(Db.videos.folderId, folderIds), + eq(Db.videos.ownerId, principal.id), + ), + ); + } else if (folder.spaceId === folder.organizationId) { + await tx + .update(Db.sharedVideos) + .set({ folderId: folder.parentId }) + .where( + and( + inArray(Db.sharedVideos.folderId, folderIds), + eq( + Db.sharedVideos.organizationId, + folder.organizationId, + ), + ), + ); + } else { + await tx + .update(Db.spaceVideos) + .set({ folderId: folder.parentId }) + .where( + and( + inArray(Db.spaceVideos.folderId, folderIds), + eq(Db.spaceVideos.spaceId, folder.spaceId), + ), + ); + } + await tx + .delete(Db.folders) + .where(inArray(Db.folders.id, folderIds)); + return { + state: "success", + response: mutationResponse( + "folder", + path.folderId, + "deleted", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("createSpace", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const name = payload.name.trim(); + const description = payload.description?.trim() || null; + if (!name || name.length > 255) { + return yield* badRequest(requestId, "Space name is invalid"); + } + if ((description?.length ?? 0) > 1_000) { + return yield* badRequest( + requestId, + "Space description is too long", + ); + } + if (payload.settings?.defaultPlaybackSpeed !== undefined) { + return yield* badRequest( + requestId, + "defaultPlaybackSpeed is not supported for spaces", + ); + } + const management = yield* AgentManagement; + yield* management.requireOrganizationManager( + principal.id, + path.organizationId, + ); + const organization = normalizeOrganization( + yield* management.getOrganization( + principal.id, + path.organizationId, + ), + principal.scopes, + ); + if ( + (payload.public || + hasProSpaceSettingEnabled(payload.settings ?? {})) && + organization.billing.plan !== "pro" + ) { + return yield* forbidden( + requestId, + "Cap Pro is required for these space settings", + ); + } + const spaceId = Space.SpaceId.make(nanoId()); + return yield* runAgentMutation({ + principal, + operation: "create_space", + idempotencyKey: yield* requestIdempotencyKey, + request: { + path, + payload: { ...payload, name, description }, + spaceId, + }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [existing] = await tx + .select({ id: Db.spaces.id }) + .from(Db.spaces) + .where( + and( + eq(Db.spaces.organizationId, path.organizationId), + eq(Db.spaces.name, name), + ), + ) + .limit(1) + .for("update"); + if (existing) return { state: "conflict" }; + const now = new Date(); + await tx.insert(Db.spaces).values({ + id: spaceId, + name, + description, + organizationId: path.organizationId, + createdById: principal.id, + privacy: payload.privacy ?? "Private", + public: payload.public ?? false, + settings: mergeSpaceViewerSettings( + null, + payload.settings ?? {}, + ), + createdAt: now, + updatedAt: now, + }); + await tx.insert(Db.spaceMembers).values({ + id: nanoId(), + spaceId, + userId: principal.id, + role: "admin", + createdAt: now, + updatedAt: now, + }); + return { + state: "success", + response: mutationResponse( + "space", + spaceId, + "created", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateSpace", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + if (Object.values(payload).every((value) => value === undefined)) { + return yield* badRequest( + requestId, + "At least one space field is required", + ); + } + const name = payload.name?.trim(); + const description = payload.description?.trim() || null; + if (name !== undefined && (!name || name.length > 255)) { + return yield* badRequest(requestId, "Space name is invalid"); + } + if ((description?.length ?? 0) > 1_000) { + return yield* badRequest( + requestId, + "Space description is too long", + ); + } + if (payload.settings?.defaultPlaybackSpeed !== undefined) { + return yield* badRequest( + requestId, + "defaultPlaybackSpeed is not supported for spaces", + ); + } + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + if (!access.canManage) return yield* forbidden(requestId); + const organization = normalizeOrganization( + yield* management.getOrganization( + principal.id, + access.organizationId, + ), + principal.scopes, + ); + if ( + (payload.public === true || + hasProSpaceSettingEnabled(payload.settings ?? {})) && + organization.billing.plan !== "pro" + ) { + return yield* forbidden( + requestId, + "Cap Pro is required for these space settings", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_space", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload: { ...payload, name, description } }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [space] = await tx + .select({ + settings: Db.spaces.settings, + public: Db.spaces.public, + }) + .from(Db.spaces) + .where(eq(Db.spaces.id, path.spaceId)) + .limit(1) + .for("update"); + if (!space) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.spaces) + .set({ + name, + description: + payload.description === undefined + ? undefined + : description, + privacy: payload.privacy, + public: payload.public, + settings: payload.settings + ? mergeSpaceViewerSettings( + space.settings, + payload.settings, + ) + : undefined, + updatedAt: now, + }) + .where(eq(Db.spaces.id, path.spaceId)); + return { + state: "success", + response: mutationResponse( + "space", + path.spaceId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateSpacePublicPage", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentCollectionPublicPage({ + target: { type: "space", spaceId: path.spaceId }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("updateSpaceLogo", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "space", spaceId: path.spaceId }, + payload, + requestId, + }), + requestId, + ); + }) + .handle("removeSpaceLogo", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + updateAgentImage({ + target: { type: "space", spaceId: path.spaceId }, + payload: null, + requestId, + }), + requestId, + ); + }) + .handle("deleteSpace", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + if (!access.canManage) return yield* forbidden(requestId); + return yield* runAgentMutation({ + principal, + operation: "delete_space", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [space] = await tx + .select({ primary: Db.spaces.primary }) + .from(Db.spaces) + .where(eq(Db.spaces.id, path.spaceId)) + .limit(1) + .for("update"); + if (!space) return { state: "not_found" }; + if (space.primary) return { state: "conflict" }; + await tx + .delete(Db.spaceVideos) + .where(eq(Db.spaceVideos.spaceId, path.spaceId)); + await tx + .delete(Db.spaceMembers) + .where(eq(Db.spaceMembers.spaceId, path.spaceId)); + await tx + .delete(Db.folders) + .where(eq(Db.folders.spaceId, path.spaceId)); + await tx + .delete(Db.spaces) + .where(eq(Db.spaces.id, path.spaceId)); + return { + state: "success", + response: mutationResponse( + "space", + path.spaceId, + "deleted", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("addSpaceMember", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + if (!access.canManage) return yield* forbidden(requestId); + return yield* runAgentMutation({ + principal, + operation: "add_space_member", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [[organizationMember], [existing]] = await Promise.all([ + tx + .select({ id: Db.organizationMembers.id }) + .from(Db.organizationMembers) + .where( + and( + eq( + Db.organizationMembers.organizationId, + access.organizationId, + ), + eq(Db.organizationMembers.userId, payload.userId), + ), + ) + .limit(1), + tx + .select({ id: Db.spaceMembers.id }) + .from(Db.spaceMembers) + .where( + and( + eq(Db.spaceMembers.spaceId, path.spaceId), + eq(Db.spaceMembers.userId, payload.userId), + ), + ) + .limit(1) + .for("update"), + ]); + if (!organizationMember) return { state: "forbidden" }; + if (existing) return { state: "conflict" }; + const now = new Date(); + await tx.insert(Db.spaceMembers).values({ + id: nanoId(), + spaceId: path.spaceId, + userId: payload.userId, + role: payload.role ?? "member", + createdAt: now, + updatedAt: now, + }); + return { + state: "success", + response: mutationResponse( + "space_member", + payload.userId, + "added", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("updateSpaceMember", ({ path, payload }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + if (!access.canManage) return yield* forbidden(requestId); + if ( + path.userId === access.createdById && + payload.role !== "admin" + ) { + return yield* forbidden( + requestId, + "The space creator must remain an admin", + ); + } + return yield* runAgentMutation({ + principal, + operation: "update_space_member", + idempotencyKey: yield* requestIdempotencyKey, + request: { path, payload }, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [member] = await tx + .select({ id: Db.spaceMembers.id }) + .from(Db.spaceMembers) + .where( + and( + eq(Db.spaceMembers.spaceId, path.spaceId), + eq(Db.spaceMembers.userId, path.userId), + ), + ) + .limit(1) + .for("update"); + if (!member) return { state: "not_found" }; + const now = new Date(); + await tx + .update(Db.spaceMembers) + .set({ role: payload.role, updatedAt: now }) + .where(eq(Db.spaceMembers.id, member.id)); + return { + state: "success", + response: mutationResponse( + "space_member", + path.userId, + "updated", + requestId, + now, + ), + }; + }, + }); + }), + requestId, + ); + }) + .handle("removeSpaceMember", ({ path }) => { + const requestId = makeRequestId(); + return withMappedErrors( + Effect.gen(function* () { + yield* requireAgentWrites(requestId); + const principal = yield* Agent.AgentPrincipal; + yield* requireScope(principal, "library:write", requestId); + const management = yield* AgentManagement; + const access = yield* management.getSpaceAccess( + principal.id, + path.spaceId, + ); + if (!access.canManage) return yield* forbidden(requestId); + if (path.userId === access.createdById) { + return yield* forbidden( + requestId, + "The space creator cannot be removed", + ); + } + return yield* runAgentMutation({ + principal, + operation: "remove_space_member", + idempotencyKey: yield* requestIdempotencyKey, + request: path, + requestId, + decodeReplay: decodeMutationResponse, + execute: async (tx) => { + const [member] = await tx + .select({ id: Db.spaceMembers.id }) + .from(Db.spaceMembers) + .where( + and( + eq(Db.spaceMembers.spaceId, path.spaceId), + eq(Db.spaceMembers.userId, path.userId), + ), + ) + .limit(1) + .for("update"); + if (!member) return { state: "not_found" }; + await tx + .delete(Db.spaceMembers) + .where(eq(Db.spaceMembers.id, member.id)); + return { + state: "success", + response: mutationResponse( + "space_member", + path.userId, + "removed", + requestId, + ), + }; + }, + }); + }), + requestId, + ); + }), +); + +const AgentAuthHandlersLive = HttpApiBuilder.group( + Agent.AgentApiContract, + "agentAuth", + (handlers) => + handlers + .handle("exchangeToken", ({ payload }) => { + const requestId = makeRequestId(); + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + headers.set(name, value); + } + if ( + yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.AGENT_TOKEN_EXCHANGE, { + headers, + }), + ) + ) { + return yield* rateLimited(requestId); + } + return yield* exchangeAgentAuthorizationCode(payload, requestId); + }).pipe( + Effect.catchTag("DatabaseError", () => + Effect.fail( + temporarilyUnavailable( + requestId, + "Authentication is temporarily unavailable", + ), + ), + ), + Effect.tapErrorCause(Effect.logError), + ); + }) + .handle("getAuthStatus", () => getAgentAuthStatus(makeRequestId())) + .handle("revokeToken", () => { + const requestId = makeRequestId(); + return revokeAgentAccessToken(requestId).pipe( + Effect.catchTag("DatabaseError", () => + Effect.fail( + temporarilyUnavailable( + requestId, + "Authentication is temporarily unavailable", + ), + ), + ), + Effect.tapErrorCause(Effect.logError), + ); + }), +); + +const ApiLive = HttpApiBuilder.api(Agent.AgentApiContract).pipe( + Layer.provide( + Layer.mergeAll( + AgentHandlersLive, + AgentManagementHandlersLive, + AgentAuthHandlersLive, + ), + ), +); + +const handler = agentApiToHandler(ApiLive); + +export const GET = handler; +export const OPTIONS = handler; +export const POST = handler; +export const PATCH = handler; +export const PUT = handler; +export const DELETE = handler; From c38d53b9e313a7ae57e9bd52de36f35d09a9eba8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 19/58] feat(web): add CLI OAuth authorize page --- apps/web/app/cli/authorize/page.tsx | 176 ++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 apps/web/app/cli/authorize/page.tsx diff --git a/apps/web/app/cli/authorize/page.tsx b/apps/web/app/cli/authorize/page.tsx new file mode 100644 index 00000000000..3d9218a73ca --- /dev/null +++ b/apps/web/app/cli/authorize/page.tsx @@ -0,0 +1,176 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { nanoId } from "@cap/database/helpers"; +import { agentApiAuthorizationCodes } from "@cap/database/schema"; +import { Logo } from "@cap/ui"; +import { redirect } from "next/navigation"; +import { + buildAgentCallbackUrl, + createAgentAuthorizationCode, + hashAgentSecret, + parseAgentAuthorizationRequest, +} from "@/lib/agent-auth"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +type AuthorizeSearchParams = Record; + +const scopeDescriptions: Record = { + "caps:read": "Read Caps, transcripts, and activity", + "caps:comment": "Post comments and reactions", + "caps:write": "Change Cap titles, visibility, and settings", + "profile:read": "Read your Cap profile", + "profile:write": "Update your Cap profile", + "caps:upload": "Upload recordings and imported media", + "caps:process": "Start paid transcription, AI, translation, and edits", + "caps:delete": "Delete Caps after explicit confirmation", + "library:read": "Read folders, spaces, and sharing state", + "library:write": "Manage folders, spaces, and sharing", + "analytics:read": "Read Cap and workspace analytics", + "organizations:read": "Read your organizations and members", + "organizations:manage": "Manage organization settings", + "organizations:members": "Invite and manage organization members", + "notifications:read": "Read notifications and preferences", + "notifications:write": "Update notifications and preferences", + "integrations:read": "Read storage and domain integration status", + "integrations:write": "Manage storage, domains, and branding", + "billing:read": "Read subscription and billing status", + "billing:write": "Start approved billing changes", + "developer:read": "Read developer apps, usage, and credits", + "developer:write": "Manage developer apps and domains", + "developer:secrets": "Create developer secrets after secure approval", +}; + +const authorizationPath = (params: AuthorizeSearchParams) => { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (typeof value === "string") query.set(key, value); + } + return `/cli/authorize?${query.toString()}`; +}; + +export default async function CliAuthorizePage(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + const request = parseAgentAuthorizationRequest(searchParams); + if (!request) { + return ( +
+
+ +

+ Invalid authorization request +

+

+ Return to the terminal and run cap auth login again. +

+
+
+ ); + } + + const user = await getCurrentUser(); + if (!user) { + redirect( + `/login?next=${encodeURIComponent(authorizationPath(searchParams))}`, + ); + } + + async function approve(formData: FormData) { + "use server"; + const submitted = parseAgentAuthorizationRequest({ + client_id: formData.get("clientId")?.toString(), + redirect_uri: formData.get("redirectUri")?.toString(), + response_type: "code", + state: formData.get("state")?.toString(), + code_challenge: formData.get("codeChallenge")?.toString(), + code_challenge_method: "S256", + scope: formData.get("scope")?.toString(), + }); + if (!submitted) throw new Error("Invalid authorization request"); + const currentUser = await getCurrentUser(); + if (!currentUser) { + redirect("/login"); + } + if ( + await isRateLimited(RATE_LIMIT_IDS.AGENT_AUTHORIZATION, { + key: `agent-authorization:${currentUser.id}`, + }) + ) { + throw new Error("Too many authorization attempts. Try again later."); + } + const code = createAgentAuthorizationCode(); + await db() + .insert(agentApiAuthorizationCodes) + .values({ + id: nanoId(), + userId: currentUser.id, + codeHash: hashAgentSecret(code), + codeChallenge: submitted.codeChallenge, + redirectUri: submitted.redirectUri, + scopes: submitted.scopes, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + const callback = buildAgentCallbackUrl(submitted.redirectUri, { + state: submitted.state, + code, + }); + if (!callback) throw new Error("Invalid callback URL"); + redirect(callback); + } + + const deniedCallback = buildAgentCallbackUrl(request.redirectUri, { + state: request.state, + error: "access_denied", + }); + + return ( +
+
+ +

+ Authorize Cap CLI +

+

+ The CLI will be able to access your Cap library with the following + permissions: +

+
    + {request.scopes.map((scope) => ( +
  • + {scopeDescriptions[scope]} +
  • + ))} +
+
+ + + + + + + + Cancel + +
+

+ Signed in as {user.email}. The CLI never receives your password. +

+
+
+ ); +} From f0fb8553b95da41286b623abe8b9d988049ff6f3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 20/58] feat(web): add CLI OAuth complete page --- apps/web/app/cli/complete/page.tsx | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/web/app/cli/complete/page.tsx diff --git a/apps/web/app/cli/complete/page.tsx b/apps/web/app/cli/complete/page.tsx new file mode 100644 index 00000000000..32458a30389 --- /dev/null +++ b/apps/web/app/cli/complete/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Cap CLI", +}; + +type PageProps = { + searchParams: Promise>; +}; + +export default async function CliCompletePage({ searchParams }: PageProps) { + const values = Object.values(await searchParams).flatMap((value) => + Array.isArray(value) ? value : value ? [value] : [], + ); + const cancelled = values.includes("cancelled"); + + return ( +
+
+
+ C +
+

+ {cancelled ? "Action cancelled" : "Action complete"} +

+

+ {cancelled + ? "No changes were made. You can close this window and return to your terminal." + : "You can close this window and return to your terminal. Cap CLI can now continue."} +

+
+
+ ); +} From 5bb0993fadd1baf1776861a679825636a5846abe Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 21/58] feat(web): support agent Google Drive OAuth redirects --- .../web/app/api/desktop/[...route]/storage.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/storage.ts b/apps/web/app/api/desktop/[...route]/storage.ts index 7644ebae9a6..fbdc28604ea 100644 --- a/apps/web/app/api/desktop/[...route]/storage.ts +++ b/apps/web/app/api/desktop/[...route]/storage.ts @@ -38,6 +38,7 @@ const GoogleDriveOAuthState = z.object({ expiresAt: z.number(), scope: z.enum(["user", "organization"]).default("user"), organizationId: z.string().optional(), + agent: z.boolean().optional(), }); const googleDriveProvider = "googleDrive"; @@ -107,6 +108,7 @@ const verifyGoogleDriveState = (state: string) => { parsed.scope === "organization" && parsed.organizationId ? Organisation.OrganisationId.make(parsed.organizationId) : undefined, + agent: parsed.agent === true, }; }; @@ -410,10 +412,34 @@ app.route("/", protectedApp); app.get("/google-drive/callback", async (c) => { const orgRedirectUrl = "/dashboard/settings/organization/integrations"; + const agentSuccessRedirectUrl = "/cli/complete?googleDrive=connected"; + const agentCancelledRedirectUrl = "/cli/complete?googleDrive=cancelled"; let organizationIdForRedirect: Organisation.OrganisationId | undefined; + let agentRedirect = false; try { const error = c.req.query("error"); if (error) { + const state = c.req.query("state"); + if (state) { + let agentRequest = false; + try { + agentRequest = verifyGoogleDriveState(state).agent; + } catch { + agentRequest = false; + } + if (agentRequest) { + return c.html( + htmlResponse({ + title: "Google Drive was not connected", + body: "No changes were made. You can return to your terminal and try again.", + redirectUrl: agentCancelledRedirectUrl, + redirectLabel: "Finish", + redirectSeconds: 5, + }), + 400, + ); + } + } return c.html( htmlResponse({ title: "Google Drive was not connected", @@ -435,8 +461,9 @@ app.get("/google-drive/callback", async (c) => { ); } - const { userId, organizationId } = verifyGoogleDriveState(state); + const { userId, organizationId, agent } = verifyGoogleDriveState(state); organizationIdForRedirect = organizationId; + agentRedirect = agent; if (organizationId) { const organization = await requireOrganizationOwner( userId, @@ -558,8 +585,10 @@ app.get("/google-drive/callback", async (c) => { ? { title: "Google Drive connected", body: 'Your Google account is now linked to Cap. We\'ve created a "Cap" folder in your Drive to store your recordings.', - redirectUrl: orgRedirectUrl, - redirectLabel: "Back to settings", + redirectUrl: agentRedirect + ? agentSuccessRedirectUrl + : orgRedirectUrl, + redirectLabel: agentRedirect ? "Finish" : "Back to settings", redirectSeconds: 5, } : { @@ -575,9 +604,13 @@ app.get("/google-drive/callback", async (c) => { organizationIdForRedirect ? { title: "Google Drive was not connected", - body: "Something went wrong while linking your Google account. You can try again from Cap settings.", - redirectUrl: orgRedirectUrl, - redirectLabel: "Back to settings", + body: agentRedirect + ? "Something went wrong while linking your Google account. You can return to your terminal and try again." + : "Something went wrong while linking your Google account. You can try again from Cap settings.", + redirectUrl: agentRedirect + ? agentCancelledRedirectUrl + : orgRedirectUrl, + redirectLabel: agentRedirect ? "Finish" : "Back to settings", redirectSeconds: 8, } : { From 8e78533a8c717a430e0afca94a3d2b5852cdd16d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 22/58] feat(web): add agent API cleanup cron job --- .../app/api/cron/cleanup-agent-api/route.ts | 28 +++++++++++++++++++ apps/web/vercel.json | 4 +++ 2 files changed, 32 insertions(+) create mode 100644 apps/web/app/api/cron/cleanup-agent-api/route.ts diff --git a/apps/web/app/api/cron/cleanup-agent-api/route.ts b/apps/web/app/api/cron/cleanup-agent-api/route.ts new file mode 100644 index 00000000000..8b264b61939 --- /dev/null +++ b/apps/web/app/api/cron/cleanup-agent-api/route.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { cleanupExpiredAgentApiRecords } from "@/lib/agent-api-cleanup"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const deleted = await cleanupExpiredAgentApiRecords(); + return NextResponse.json({ success: true, deleted }); +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index c1ae294c4c7..0b0ba9f67de 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,6 +1,10 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "crons": [ + { + "path": "/api/cron/cleanup-agent-api", + "schedule": "17 3 * * *" + }, { "path": "/api/cron/finalize-stale-desktop-segments", "schedule": "*/15 * * * *" From 74e141c0d2c9d0fadfa037e1c1dcb5de3c808be3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 23/58] test(web): add agent API fixtures and core unit tests --- apps/web/__tests__/fixtures/agent-caps.ts | 86 +++++++++ .../__tests__/unit/agent-access-grant.test.ts | 53 ++++++ apps/web/__tests__/unit/agent-api.test.ts | 165 ++++++++++++++++++ apps/web/__tests__/unit/agent-auth.test.ts | 157 +++++++++++++++++ .../__tests__/unit/agent-management.test.ts | 88 ++++++++++ 5 files changed, 549 insertions(+) create mode 100644 apps/web/__tests__/fixtures/agent-caps.ts create mode 100644 apps/web/__tests__/unit/agent-access-grant.test.ts create mode 100644 apps/web/__tests__/unit/agent-api.test.ts create mode 100644 apps/web/__tests__/unit/agent-auth.test.ts create mode 100644 apps/web/__tests__/unit/agent-management.test.ts diff --git a/apps/web/__tests__/fixtures/agent-caps.ts b/apps/web/__tests__/fixtures/agent-caps.ts new file mode 100644 index 00000000000..87d689a6314 --- /dev/null +++ b/apps/web/__tests__/fixtures/agent-caps.ts @@ -0,0 +1,86 @@ +export const syntheticAgentCaps = [ + { + name: "owner-ready", + access: "owned", + public: true, + protected: false, + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "COMPLETE", + durationMs: 188_000, + comments: 2, + reactions: 1, + }, + { + name: "organization-share", + access: "shared", + public: false, + protected: false, + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "COMPLETE", + durationMs: 420_000, + comments: 4, + reactions: 3, + }, + { + name: "space-share", + access: "shared", + public: false, + protected: false, + transcriptionStatus: "PROCESSING", + aiGenerationStatus: "QUEUED", + durationMs: 94_000, + comments: 0, + reactions: 0, + }, + { + name: "public-direct", + access: "shared", + public: true, + protected: false, + transcriptionStatus: "SKIPPED", + aiGenerationStatus: "SKIPPED", + durationMs: 42_000, + comments: 1, + reactions: 0, + }, + { + name: "password-protected", + access: "shared", + public: true, + protected: true, + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "COMPLETE", + durationMs: 610_000, + comments: 3, + reactions: 2, + }, + { + name: "content-disabled", + access: "shared", + public: true, + protected: false, + transcriptionStatus: "COMPLETE", + aiGenerationStatus: "COMPLETE", + durationMs: 215_000, + comments: 0, + reactions: 0, + }, + { + name: "processing-error", + access: "owned", + public: false, + protected: false, + transcriptionStatus: "ERROR", + aiGenerationStatus: "ERROR", + durationMs: 19_800_000, + comments: 0, + reactions: 0, + }, +] as const; + +export const makeSyntheticVtt = (targetBytes: number) => { + const header = "WEBVTT\n\n"; + const cue = + "1\n00:00:00.000 --> 00:00:02.000\nSynthetic transcript content only.\n\n"; + return `${header}${cue.repeat(Math.max(1, Math.ceil(targetBytes / cue.length)))}`; +}; diff --git a/apps/web/__tests__/unit/agent-access-grant.test.ts b/apps/web/__tests__/unit/agent-access-grant.test.ts new file mode 100644 index 00000000000..2813ac20e28 --- /dev/null +++ b/apps/web/__tests__/unit/agent-access-grant.test.ts @@ -0,0 +1,53 @@ +import { User, Video } from "@cap/web-domain"; +import { describe, expect, it, vi } from "vitest"; +import { validateAgentAccessGrantPayload } from "@/lib/agent-access-grant"; + +vi.mock("server-only", () => ({})); + +const videoId = Video.VideoId.make("cap_synthetic_grant"); +const userId = User.UserId.make("user_synthetic_grant"); + +describe("agent access grants", () => { + it("accepts only a live grant bound to the same user and Cap", () => { + const value = { + videoId, + userId, + passwordHash: "h".repeat(64), + expiresAt: "2026-07-18T18:30:00.000Z", + }; + expect( + validateAgentAccessGrantPayload( + value, + videoId, + userId, + Date.parse("2026-07-18T18:20:00.000Z"), + ), + ).toMatchObject({ passwordHash: "h".repeat(64) }); + expect( + validateAgentAccessGrantPayload( + value, + videoId, + User.UserId.make("different_user"), + Date.parse("2026-07-18T18:20:00.000Z"), + ), + ).toBeNull(); + expect( + validateAgentAccessGrantPayload( + value, + videoId, + userId, + Date.parse("2026-07-18T18:31:00.000Z"), + ), + ).toBeNull(); + }); + + it("rejects malformed payloads without exposing their contents", () => { + expect( + validateAgentAccessGrantPayload( + { videoId, userId, passwordHash: "short", expiresAt: "invalid" }, + videoId, + userId, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/agent-api.test.ts b/apps/web/__tests__/unit/agent-api.test.ts new file mode 100644 index 00000000000..ce3bebf23ed --- /dev/null +++ b/apps/web/__tests__/unit/agent-api.test.ts @@ -0,0 +1,165 @@ +import { Agent, Video } from "@cap/web-domain"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { + agentCapabilities, + agentStatus, + agentTranscriptRevision, + decodeAgentCursor, + encodeAgentCursor, + parseAgentDate, + parseAgentLimit, + parseAgentVtt, + renderAgentVtt, + safeDownloadFileName, + transcriptTextFromCues, +} from "@/lib/agent-api"; +import { makeSyntheticVtt, syntheticAgentCaps } from "../fixtures/agent-caps"; + +describe("agent API normalization", () => { + it("pins pagination defaults and opaque cursor round trips", () => { + expect(parseAgentLimit(undefined)).toBe(50); + expect(parseAgentLimit("250")).toBe(100); + expect(parseAgentLimit("0")).toBeNull(); + expect(parseAgentLimit("nope")).toBeNull(); + + const cursor = { + updatedAt: "2026-07-18T12:00:00.000Z", + id: "cap_synthetic_1", + }; + expect(decodeAgentCursor(encodeAgentCursor(cursor))).toEqual(cursor); + expect(decodeAgentCursor("not-a-cursor")).toBeUndefined(); + expect(decodeAgentCursor("a".repeat(1_025))).toBeUndefined(); + expect( + decodeAgentCursor( + Buffer.from( + JSON.stringify({ updatedAt: cursor.updatedAt, id: "bad id" }), + ).toString("base64url"), + ), + ).toBeUndefined(); + }); + + it("accepts only valid ISO-compatible updated-after values", () => { + expect(parseAgentDate(undefined)).toBeNull(); + expect(parseAgentDate("2026-07-18T12:00:00Z")?.toISOString()).toBe( + "2026-07-18T12:00:00.000Z", + ); + expect(parseAgentDate("not-a-date")).toBeUndefined(); + expect(parseAgentDate("2026-07-18T13:00:00+01:00")).toBeUndefined(); + expect(parseAgentDate("2026-02-30T12:00:00Z")).toBeUndefined(); + }); + + it("normalizes VTT into millisecond cues and text", () => { + const cues = parseAgentVtt( + "WEBVTT\r\n\r\n1\r\n00:00:01.250 --> 00:00:03.500 align:start\r\nHello agent\r\n\r\n2\r\n00:00:04,000 --> 00:00:05,125\r\nSecond line\r\n", + ); + + expect(cues).toEqual([ + { startMs: 1_250, endMs: 3_500, text: "Hello agent" }, + { startMs: 4_000, endMs: 5_125, text: "Second line" }, + ]); + expect(transcriptTextFromCues(cues)).toBe("Hello agent\nSecond line"); + const rendered = renderAgentVtt(cues); + expect(parseAgentVtt(rendered)).toEqual(cues); + expect(agentTranscriptRevision(rendered)).toMatch(/^[a-f0-9]{64}$/); + }); + + it("handles representative and large synthetic transcript shapes", () => { + for (const bytes of [10_000, 100_000, 500_000]) { + const vtt = makeSyntheticVtt(bytes); + expect(vtt.length).toBeGreaterThanOrEqual(bytes); + expect(parseAgentVtt(vtt).length).toBeGreaterThan(0); + } + }); + + it("reports content and mutation capabilities without inference", () => { + const capabilities = agentCapabilities({ + isOwner: false, + hasReadScope: true, + hasCommentScope: true, + hasWriteScope: true, + hasProcessScope: true, + hasDeleteScope: true, + passwordRequired: false, + transcriptStatus: "COMPLETE", + hasSummary: true, + hasChapters: true, + settings: { + disableSummary: false, + disableChapters: false, + disableTranscript: true, + disableComments: false, + disableReactions: true, + }, + }); + + expect(capabilities.transcript).toEqual({ + allowed: false, + reason: "CONTENT_DISABLED", + }); + expect(capabilities.editTitle).toEqual({ + allowed: false, + reason: "OWNER_ONLY", + }); + expect(capabilities.comment.allowed).toBe(true); + expect(capabilities.react.reason).toBe("CONTENT_DISABLED"); + expect( + Schema.decodeUnknownSync(Agent.AgentCapabilities)(capabilities), + ).toEqual(capabilities); + }); + + it("locks every read and write surface for protected shared Caps", () => { + const capabilities = agentCapabilities({ + isOwner: false, + hasReadScope: true, + hasCommentScope: true, + hasWriteScope: true, + hasProcessScope: true, + hasDeleteScope: true, + passwordRequired: true, + transcriptStatus: "COMPLETE", + hasSummary: true, + hasChapters: true, + settings: { + disableSummary: false, + disableChapters: false, + disableTranscript: false, + disableComments: false, + disableReactions: false, + }, + }); + + for (const value of Object.values(capabilities)) { + expect(value).toEqual({ + allowed: false, + reason: "PASSWORD_REQUIRED", + }); + } + }); + + it("maps processing states without starting work", () => { + const fixture = syntheticAgentCaps.find( + (cap) => cap.name === "space-share", + ); + expect(fixture).toBeDefined(); + const status = agentStatus({ + id: Video.VideoId.make("cap_synthetic_processing"), + updatedAt: new Date("2026-07-18T12:00:00.000Z"), + transcriptionStatus: fixture?.transcriptionStatus ?? null, + aiGenerationStatus: fixture?.aiGenerationStatus, + uploadPhase: "complete", + uploadError: null, + }); + + expect(status.overall).toBe("processing"); + expect(status.transcript.status).toBe("processing"); + expect(status.ai.status).toBe("queued"); + }); + + it("creates portable download names", () => { + expect(safeDownloadFileName(" Q3: Roadmap / review? ")).toBe( + "Q3-Roadmap-review.mp4", + ); + expect(safeDownloadFileName("🔥")).toBe("cap-recording.mp4"); + }); +}); diff --git a/apps/web/__tests__/unit/agent-auth.test.ts b/apps/web/__tests__/unit/agent-auth.test.ts new file mode 100644 index 00000000000..9421241c55b --- /dev/null +++ b/apps/web/__tests__/unit/agent-auth.test.ts @@ -0,0 +1,157 @@ +import { createHash } from "node:crypto"; +import { isLegacyAgentKeySource } from "@cap/web-backend"; +import { describe, expect, it } from "vitest"; +import { + buildAgentCallbackUrl, + createAgentAccessToken, + hashAgentSecret, + isAgentCodeVerifier, + isAgentLoopbackRedirectUri, + isAgentReadAccessEnabled, + parseAgentAuthorizationRequest, + parseAgentScopes, + verifyAgentCodeChallenge, +} from "@/lib/agent-auth"; + +const state = "s".repeat(43); +const verifier = "v".repeat(43); +const challenge = createHash("sha256").update(verifier).digest("base64url"); + +describe("agent browser authorization", () => { + it("only accepts explicit loopback callback URLs", () => { + expect(isAgentLoopbackRedirectUri("http://127.0.0.1:49152/callback")).toBe( + true, + ); + expect(isAgentLoopbackRedirectUri("http://[::1]:49152/callback")).toBe( + true, + ); + for (const value of [ + "https://127.0.0.1:49152/callback", + "http://localhost:49152/callback", + "http://127.0.0.1/callback", + "http://127.0.0.1:49152/other", + "http://127.0.0.1:49152/callback?next=https://example.com", + "http://example.com:49152/callback", + ]) { + expect(isAgentLoopbackRedirectUri(value)).toBe(false); + } + }); + + it("requires PKCE S256, state, and known scopes", () => { + const request = parseAgentAuthorizationRequest({ + client_id: "cap-cli", + redirect_uri: "http://127.0.0.1:49152/callback", + response_type: "code", + state, + code_challenge: challenge, + code_challenge_method: "S256", + scope: "caps:read caps:comment", + }); + expect(request).toMatchObject({ + clientId: "cap-cli", + scopes: ["caps:read", "caps:comment"], + }); + expect( + parseAgentAuthorizationRequest({ + client_id: "cap-cli", + redirect_uri: "http://127.0.0.1:49152/callback", + response_type: "code", + state, + code_challenge: challenge, + code_challenge_method: "plain", + scope: "caps:read", + }), + ).toBeNull(); + expect(parseAgentScopes("caps:comment")).toBeNull(); + expect(parseAgentScopes("caps:read unknown")).toBeNull(); + expect( + parseAgentScopes( + "caps:read caps:upload library:write organizations:manage developer:secrets", + ), + ).toEqual([ + "caps:read", + "caps:upload", + "library:write", + "organizations:manage", + "developer:secrets", + ]); + }); + + it("verifies the RFC 7636 challenge", () => { + expect(isAgentCodeVerifier(verifier)).toBe(true); + expect(verifyAgentCodeChallenge(verifier, challenge)).toBe(true); + expect(verifyAgentCodeChallenge("x".repeat(43), challenge)).toBe(false); + }); + + it("builds a callback without accepting an open redirect", () => { + const callback = buildAgentCallbackUrl("http://127.0.0.1:49152/callback", { + state, + code: "one-time-code", + }); + expect(callback).toBe( + `http://127.0.0.1:49152/callback?state=${state}&code=one-time-code`, + ); + expect( + buildAgentCallbackUrl("https://example.com/callback", { + state, + code: "code", + }), + ).toBeNull(); + }); + + it("hashes credentials and never embeds the raw value", () => { + const token = createAgentAccessToken(); + expect(token).toMatch(/^cap_cli_[A-Za-z0-9_-]{43}$/); + expect(hashAgentSecret(token)).toMatch(/^[a-f0-9]{64}$/); + expect(hashAgentSecret(token)).not.toContain(token); + }); +}); + +describe("agent API dark launch", () => { + it("requires both the production switch and exact allowlist membership", () => { + expect( + isAgentReadAccessEnabled({ + nodeEnv: "production", + enabled: "true", + allowlist: "agent-test@cap.so", + email: "agent-test@cap.so", + }), + ).toBe(true); + expect( + isAgentReadAccessEnabled({ + nodeEnv: "production", + enabled: "false", + allowlist: "agent-test@cap.so", + email: "agent-test@cap.so", + }), + ).toBe(false); + expect( + isAgentReadAccessEnabled({ + nodeEnv: "production", + enabled: "true", + allowlist: "another@cap.so", + email: "agent-test@cap.so", + }), + ).toBe(false); + }); + + it("stays available in local and test environments", () => { + expect( + isAgentReadAccessEnabled({ + nodeEnv: "test", + enabled: undefined, + allowlist: undefined, + email: "developer@cap.so", + }), + ).toBe(true); + }); +}); + +describe("legacy agent credentials", () => { + it("accepts desktop-era keys without extending mobile or extension keys", () => { + expect(isLegacyAgentKeySource("desktop")).toBe(true); + expect(isLegacyAgentKeySource("unknown")).toBe(true); + expect(isLegacyAgentKeySource("mobile")).toBe(false); + expect(isLegacyAgentKeySource("extension")).toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/agent-management.test.ts b/apps/web/__tests__/unit/agent-management.test.ts new file mode 100644 index 00000000000..211a6e1c84b --- /dev/null +++ b/apps/web/__tests__/unit/agent-management.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + decodeNotificationCursor, + encodeNotificationCursor, + libraryCapabilities, + normalizeOrganization, + organizationCapabilities, + profileCapabilities, +} from "@/lib/agent-management"; + +describe("agent management capabilities", () => { + it("requires both scope and organization role", () => { + const scopes = new Set([ + "organizations:read", + "organizations:manage", + "organizations:members", + "billing:write", + ] as const); + const member = organizationCapabilities("member", scopes); + expect(member.read.allowed).toBe(true); + expect(member.update).toMatchObject({ + allowed: false, + reason: "ROLE_REQUIRED", + }); + const owner = organizationCapabilities("owner", scopes); + expect(owner.update.allowed).toBe(true); + expect(owner.manageBilling).toMatchObject({ + allowed: true, + confirmation: "browser", + sideEffect: "paid", + }); + }); + + it("keeps read, write, and destructive metadata explicit", () => { + const scopes = new Set(["profile:read", "library:write"] as const); + expect(profileCapabilities(scopes).read).toMatchObject({ + allowed: true, + sideEffect: "read", + idempotencyRequired: false, + }); + expect(libraryCapabilities(true, scopes).delete).toMatchObject({ + allowed: true, + confirmation: "user", + sideEffect: "destructive", + idempotencyRequired: true, + }); + }); + + it("normalizes organization settings and billing without secrets", () => { + const result = normalizeOrganization( + { + id: "org_test" as never, + name: "Test", + ownerId: "user_test" as never, + role: "owner", + hasProSeat: true, + allowedEmailDomain: null, + customDomain: "caps.example.com", + domainVerifiedAt: new Date("2026-01-01T00:00:00.000Z"), + settings: { disableTranscript: true }, + icon: null, + shareableLinkIcon: null, + ownerSubscriptionStatus: "active", + ownerThirdPartySubscriptionId: null, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + new Set(["organizations:read"]), + ); + expect(result.billing).toEqual({ status: "active", plan: "pro" }); + expect(result.settings.disableTranscript).toBe(true); + expect(JSON.stringify(result)).not.toContain("stripeCustomerId"); + }); +}); + +describe("notification cursor", () => { + it("round trips opaque cursors and rejects tampering", () => { + const cursor = { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + id: "notification_1", + }; + expect(decodeNotificationCursor(encodeNotificationCursor(cursor))).toEqual( + cursor, + ); + expect(decodeNotificationCursor("not-a-cursor")).toBeUndefined(); + expect(decodeNotificationCursor("a".repeat(1_025))).toBeUndefined(); + }); +}); From 143c008e0470909f29ba0a776ce3dc3bb01cae4f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 24/58] test(web): add agent API contract and handler tests --- .../__tests__/unit/agent-api-contract.test.ts | 496 ++++++++++++++++++ .../__tests__/unit/agent-api-handler.test.ts | 263 ++++++++++ .../unit/agent-token-contract.test.ts | 36 ++ .../unit/agent-write-contract.test.ts | 62 +++ 4 files changed, 857 insertions(+) create mode 100644 apps/web/__tests__/unit/agent-api-contract.test.ts create mode 100644 apps/web/__tests__/unit/agent-api-handler.test.ts create mode 100644 apps/web/__tests__/unit/agent-token-contract.test.ts create mode 100644 apps/web/__tests__/unit/agent-write-contract.test.ts diff --git a/apps/web/__tests__/unit/agent-api-contract.test.ts b/apps/web/__tests__/unit/agent-api-contract.test.ts new file mode 100644 index 00000000000..68e9b184d3d --- /dev/null +++ b/apps/web/__tests__/unit/agent-api-contract.test.ts @@ -0,0 +1,496 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Agent, Organisation, User, Video } from "@cap/web-domain"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +const capability = { allowed: true, reason: null } as const; + +const status = { + id: Video.VideoId.make("cap_synthetic_1"), + overall: "ready" as const, + upload: { status: "complete" as const, reason: null, retryable: false }, + transcript: { status: "complete" as const, reason: null, retryable: false }, + ai: { status: "complete" as const, reason: null, retryable: false }, + updatedAt: "2026-07-18T12:00:00.000Z", +}; + +describe("agent API contract", () => { + it("verifies credentials online and rate limits authorization boundaries", () => { + const contract = readFileSync( + join(process.cwd(), "../../packages/web-domain/src/Agent.ts"), + "utf8", + ); + const route = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const authorize = readFileSync( + join(process.cwd(), "app/cli/authorize/page.tsx"), + "utf8", + ); + expect(contract).toContain('HttpApiEndpoint.get("getAuthStatus"'); + expect(route).toContain('.handle("getAuthStatus"'); + expect(route).toContain("RATE_LIMIT_IDS.AGENT_TOKEN_EXCHANGE"); + expect(authorize).toContain("RATE_LIMIT_IDS.AGENT_AUTHORIZATION"); + }); + + it("keeps every GET handler free of processing and mutation entry points", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const readImplementationSource = source.slice( + source.indexOf("const getViewableCap"), + source.indexOf("const unlockCap"), + ); + const readHandlerSource = source.slice( + source.indexOf('.handle("listCaps"'), + source.indexOf('.handle("unlockCap"'), + ); + for (const forbidden of [ + "startVideoProcessingWorkflow", + "generate-ai", + "generateAi", + "Workflows", + ".putObject(", + ".insert(", + ".update(", + ".delete(", + ]) { + expect(readImplementationSource).not.toContain(forbidden); + expect(readHandlerSource).not.toContain(forbidden); + } + expect(source).toContain("export const GET = handler"); + expect(source).toContain("export const OPTIONS = handler"); + expect(source).toContain("export const POST = handler"); + expect(source).toContain("export const PATCH = handler"); + expect(source).toContain("export const PUT = handler"); + expect(source).toContain("export const DELETE = handler"); + }); + + it("pins list execution to two bounded query phases", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const listSource = source.slice( + source.indexOf("const listCaps"), + source.indexOf("const readTranscript"), + ); + + expect(listSource.match(/database\.use/g)).toHaveLength(1); + expect(listSource.match(/getSpaceRules/g)).toHaveLength(1); + expect(listSource).toContain( + "union(ownedCaps, organizationCaps, spaceCaps)", + ); + expect(listSource).not.toContain("Effect.forEach"); + expect(listSource).not.toContain("getThumbnailURL"); + expect(listSource).not.toContain("getAnalytics"); + expect(listSource).not.toContain("getSignedObjectUrl"); + }); + + it("checks viewing policy before reading password hashes", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const unlockSource = source.slice( + source.indexOf("const unlockCap"), + source.indexOf("const requireAgentWrites"), + ); + + expect(unlockSource.indexOf("getViewableVideo(videoId)")).toBeGreaterThan( + -1, + ); + expect(unlockSource.indexOf("getViewableVideo(videoId)")).toBeLessThan( + unlockSource.indexOf("Db.videos.password"), + ); + }); + + it("decodes lightweight cap summaries without heavy media fields", () => { + const decoded = Schema.decodeUnknownSync(Agent.AgentCapSummary)({ + id: Video.VideoId.make("cap_synthetic_1"), + shareUrl: "https://cap.so/s/cap_synthetic_1", + title: "Synthetic roadmap review", + aiTitle: "Roadmap review", + createdAt: "2026-07-18T11:00:00.000Z", + updatedAt: "2026-07-18T12:00:00.000Z", + durationMs: 188_000, + owner: { + id: User.UserId.make("user_synthetic_owner"), + name: "Synthetic Owner", + }, + organizationId: Organisation.OrganisationId.make("org_synthetic"), + folderId: null, + access: "owned", + sharing: { public: true, protected: false }, + counts: { comments: 2, reactions: 1 }, + status, + capabilities: { + view: capability, + summary: capability, + chapters: capability, + transcript: capability, + comments: capability, + reactions: capability, + download: capability, + comment: capability, + react: capability, + editTitle: capability, + editVisibility: capability, + processTranscript: capability, + processAi: capability, + editTranscript: capability, + editPassword: capability, + duplicate: capability, + delete: capability, + }, + }); + + expect(decoded.id).toBe("cap_synthetic_1"); + expect(decoded).not.toHaveProperty("thumbnailUrl"); + expect(decoded).not.toHaveProperty("transcript"); + expect(decoded).not.toHaveProperty("downloadUrl"); + }); + + it("pins structured retry metadata on stable errors", () => { + const decoded = Schema.decodeUnknownSync(Agent.AgentApiError)({ + _tag: "AgentNotReadyError", + code: "NOT_READY", + message: "Transcript is not ready", + retryable: true, + retryAfterMs: 2_000, + requestId: "request_synthetic_1", + }); + + expect(decoded.code).toBe("NOT_READY"); + expect(decoded.retryAfterMs).toBe(2_000); + }); + + it("keeps transcript format vocabulary stable", () => { + for (const format of ["text", "json", "vtt"] as const) { + expect( + Schema.decodeUnknownSync(Agent.AgentTranscriptParams)({ format }) + .format, + ).toBe(format); + } + expect(() => + Schema.decodeUnknownSync(Agent.AgentTranscriptParams)({ format: "srt" }), + ).toThrow(); + }); + + it("pins asynchronous destructive operation state", () => { + const operation = Schema.decodeUnknownSync(Agent.AgentOperationResponse)({ + id: "operation_123", + kind: "delete_cap", + state: "running", + resourceId: Video.VideoId.make("cap_synthetic_1"), + resultResourceId: null, + result: null, + error: null, + createdAt: "2026-07-18T12:00:00.000Z", + updatedAt: "2026-07-18T12:00:01.000Z", + completedAt: null, + requestId: "request_synthetic_1", + }); + + expect(operation.state).toBe("running"); + }); + + it("keeps destructive storage work retry-safe and confirmation-gated", () => { + const route = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const workflow = readFileSync( + join(process.cwd(), "workflows/agent-cap-operation.ts"), + "utf8", + ); + expect(route).toContain('request.headers["x-cap-confirmation"]'); + expect(route).toContain("requireUserConfirmedRequest"); + expect(workflow.indexOf("await deleteCapObjects")).toBeLessThan( + workflow.indexOf("await deleteCapDatabase"), + ); + expect(workflow.indexOf("await copyCapObjects")).toBeLessThan( + workflow.indexOf("await createDuplicate"), + ); + }); + + it("keeps organization hierarchy enforcement in agent member mutations", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const memberSource = source.slice( + source.indexOf('.handle("updateOrganizationMember"'), + source.indexOf('.handle("updateMe"'), + ); + expect(memberSource).toContain("canChangeOrganizationMemberRole"); + expect(memberSource).toContain("canRemoveOrganizationMember"); + expect(memberSource).toContain("Db.spaceMembers"); + }); + + it("stores only developer key identifiers in idempotency responses", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const developerSource = source.slice( + source.indexOf('.handle("createDeveloperApp"'), + source.indexOf('.handle("getOperation"'), + ); + expect(developerSource).toContain( + "response: { appId, publicKeyId, secretKeyId }", + ); + expect(developerSource).not.toContain( + "response: { appId, publicKey: publicKeyRaw", + ); + expect(developerSource).toContain("readDeveloperCredentials"); + }); + + it("keeps image and S3 secret material out of idempotency records", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const imageSource = source.slice( + source.indexOf("const updateAgentImage"), + source.indexOf("const queueAgentCapOperation"), + ); + const s3Source = source.slice( + source.indexOf('.handle("updateOrganizationS3"'), + source.indexOf('.handle("removeOrganizationS3"'), + ); + + expect(imageSource).toContain('sha256: createHash("sha256")'); + expect(imageSource).not.toContain("data: input.payload.data"); + expect(s3Source).toContain("accessKeyIdHash"); + expect(s3Source).toContain("secretAccessKeyHash"); + expect(s3Source).not.toContain("accessKeyId: config.accessKeyId"); + expect(s3Source).not.toContain("secretAccessKey: config.secretAccessKey"); + }); + + it("delivers organization invites with provider idempotency", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const inviteSource = source.slice( + source.indexOf('.handle("createOrganizationInvite"'), + source.indexOf('.handle("deleteOrganizationInvite"'), + ); + + expect(inviteSource).toContain("payload.sendEmail ?? true"); + expect(inviteSource).toContain( + 'operation: "send_organization_invite_email"', + ); + expect(inviteSource).toContain("idempotencyKey: providerIdempotencyKey"); + expect(inviteSource).toContain('emailDelivery: "accepted"'); + }); + + it("requires explicit confirmation for agent-authored activity and metadata", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const mutationSource = source.slice( + source.indexOf('.handle("createComment"'), + source.indexOf("const AgentManagementHandlersLive"), + ); + + expect(mutationSource.match(/requireUserConfirmedRequest/g)).toHaveLength( + 4, + ); + }); + + it("advertises granular dashboard-parity confirmations", () => { + const source = readFileSync( + join(process.cwd(), "lib/agent-management.ts"), + "utf8", + ); + + expect(source).toContain("createOrganization: scopedAction"); + expect(source).toContain("configureS3: scopedRoleAction"); + expect(source).toContain('confirmation: "secure_input"'); + expect(source).toContain("connectGoogleDrive: scopedRoleAction"); + expect(source).toContain("purchaseCredits: scopedAction"); + expect(source).toContain("signOutAllDevices: scopedAction"); + expect(source).toContain("openReferrals: scopedAction"); + }); + + it("keeps normal Google Drive callbacks while isolating CLI completion", () => { + const source = readFileSync( + join(process.cwd(), "app/api/desktop/[...route]/storage.ts"), + "utf8", + ); + + expect(source).toContain( + 'const orgRedirectUrl = "/dashboard/settings/organization/integrations"', + ); + expect(source).toContain( + 'const agentSuccessRedirectUrl = "/cli/complete?googleDrive=connected"', + ); + expect(source).toContain( + 'const agentCancelledRedirectUrl = "/cli/complete?googleDrive=cancelled"', + ); + expect(source).toContain("? agentSuccessRedirectUrl"); + expect(source).toContain("? agentCancelledRedirectUrl"); + }); + + it("keeps developer video inventory bounded and secret-free", () => { + const source = readFileSync( + join(process.cwd(), "../../packages/web-backend/src/AgentManagement.ts"), + "utf8", + ); + const listSource = source.slice( + source.indexOf("listDeveloperVideos: Effect.fn"), + source.indexOf("listDeveloperTransactions: Effect.fn"), + ); + + expect(listSource).toContain(".limit(limit + 1)"); + expect(listSource).not.toContain("s3Key:"); + expect(listSource).not.toContain("metadata:"); + + const decoded = Schema.decodeUnknownSync( + Agent.AgentDeveloperVideosResponse, + )({ + videos: [ + { + id: "video_synthetic", + appId: "app_synthetic", + externalUserId: "customer_synthetic", + name: "Synthetic SDK video", + durationSeconds: 30, + width: 1920, + height: 1080, + fps: 30, + transcriptionStatus: "COMPLETE", + createdAt: "2026-07-18T12:00:00.000Z", + updatedAt: "2026-07-18T12:01:00.000Z", + capabilities: {}, + }, + ], + nextCursor: null, + requestId: "request_synthetic", + }); + expect(decoded.videos[0]?.id).toBe("video_synthetic"); + }); + + it("validates and atomically merges public collection customization", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const updateSource = source.slice( + source.indexOf("const updateAgentCollectionPublicPage"), + source.indexOf("const queueAgentCapOperation"), + ); + + expect(updateSource).toContain("AgentCollectionPublicPageInput"); + expect(updateSource).toContain("JSON_MERGE_PATCH"); + expect(updateSource).toContain("Cap Pro is required"); + expect(updateSource).toContain("getFolderAccess"); + expect(updateSource).toContain("getSpaceAccess"); + + expect( + Schema.decodeUnknownSync(Agent.AgentCollectionPublicPageInput)({ + public: true, + title: "Synthetic collection", + layout: "grid", + gridColumns: 4, + }), + ).toMatchObject({ public: true, gridColumns: 4 }); + expect(() => + Schema.decodeUnknownSync(Agent.AgentCollectionPublicPageInput)({ + gridColumns: 6, + }), + ).toThrow(); + + const publicPage = { + title: "Synthetic collection", + layout: "grid" as const, + gridColumns: 4 as const, + }; + const folderResponse = Schema.decodeUnknownSync(Agent.AgentFoldersResponse)( + { + folders: [ + { + id: "folder_synthetic", + name: "Synthetic folder", + color: "normal", + public: true, + organizationId: "org_synthetic", + createdById: "user_synthetic", + parentId: null, + spaceId: null, + settings: { publicPage }, + publicPage, + createdAt: "2026-07-18T12:00:00.000Z", + updatedAt: "2026-07-18T12:00:00.000Z", + capabilities: {}, + }, + ], + requestId: "request_synthetic", + }, + ); + const spaceResponse = Schema.decodeUnknownSync(Agent.AgentSpacesResponse)({ + spaces: [ + { + id: "space_synthetic", + name: "Synthetic space", + description: null, + organizationId: "org_synthetic", + createdById: "user_synthetic", + primary: false, + privacy: "Public", + public: true, + protected: false, + icon: null, + settings: { + disableSummary: null, + disableCaptions: null, + disableChapters: null, + disableReactions: null, + disableTranscript: null, + disableComments: null, + defaultPlaybackSpeed: null, + }, + publicPage, + role: "admin", + counts: { members: 1, caps: 2, folders: 3 }, + createdAt: "2026-07-18T12:00:00.000Z", + updatedAt: "2026-07-18T12:00:00.000Z", + capabilities: {}, + }, + ], + requestId: "request_synthetic", + }); + + expect(folderResponse.folders[0]?.publicPage).toEqual(publicPage); + expect(spaceResponse.spaces[0]?.publicPage).toEqual(publicPage); + }); + + it("queues Loom imports without persisting download URLs", () => { + const route = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const workflow = readFileSync( + join(process.cwd(), "workflows/import-loom-video.ts"), + "utf8", + ); + const importSource = route.slice( + route.indexOf("const queueAgentLoomImport"), + route.indexOf("const normalizeTranscriptCues"), + ); + + expect(importSource).toContain('operation: "import_loom"'); + expect(importSource).toContain('kind: "import_loom"'); + expect(importSource).toContain("agentOperationId: operationId"); + expect(importSource).not.toContain("loomDownloadUrl:"); + expect(workflow).toContain("claimAgentImport"); + expect(workflow).toContain("completeAgentImport"); + expect(workflow).toContain("failAgentImport"); + }); +}); diff --git a/apps/web/__tests__/unit/agent-api-handler.test.ts b/apps/web/__tests__/unit/agent-api-handler.test.ts new file mode 100644 index 00000000000..d63116e1806 --- /dev/null +++ b/apps/web/__tests__/unit/agent-api-handler.test.ts @@ -0,0 +1,263 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +const isRateLimited = vi.hoisted(() => vi.fn(async () => false)); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited, + RATE_LIMIT_IDS: { + AGENT_TOKEN_EXCHANGE: "rl_agent_token_exchange", + AGENT_AUTHORIZATION: "rl_agent_authorization", + AGENT_UNLOCK: "rl_agent_unlock", + AGENT_LOOM_IMPORT: "rl_loom_import_per_user", + }, +})); + +describe("agent API handler", () => { + let GET: typeof import("@/app/api/v1/[...route]/route").GET; + let OPTIONS: typeof import("@/app/api/v1/[...route]/route").OPTIONS; + let POST: typeof import("@/app/api/v1/[...route]/route").POST; + let PATCH: typeof import("@/app/api/v1/[...route]/route").PATCH; + let PUT: typeof import("@/app/api/v1/[...route]/route").PUT; + let DELETE: typeof import("@/app/api/v1/[...route]/route").DELETE; + + beforeAll(async () => { + Object.assign(process.env, { + NEXT_PUBLIC_WEB_URL: "http://localhost", + WEB_URL: "http://localhost", + DATABASE_URL: "mysql://unused:unused@127.0.0.1:3306/unused", + NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough", + NEXTAUTH_URL: "http://localhost", + CAP_AWS_BUCKET: "synthetic", + CAP_AWS_REGION: "us-east-1", + CAP_AWS_ACCESS_KEY: "synthetic", + CAP_AWS_SECRET_KEY: "synthetic", + NODE_ENV: "test", + }); + const route = await import("@/app/api/v1/[...route]/route"); + GET = route.GET; + OPTIONS = route.OPTIONS; + POST = route.POST; + PATCH = route.PATCH; + PUT = route.PUT; + DELETE = route.DELETE; + }); + + it("handles CORS preflight without authentication", async () => { + const response = await OPTIONS( + new Request("http://localhost/api/v1/caps", { + method: "OPTIONS", + headers: { + Origin: "http://localhost", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "x-cap-confirmation", + }, + }), + ); + expect(response.status).toBeLessThan(400); + expect(response.headers.get("access-control-allow-headers")).toContain( + "X-Cap-Confirmation", + ); + }); + + it("returns the stable authentication error without a credential", async () => { + for (const path of ["/api/v1/caps", "/api/v1/auth/status"]) { + const response = await GET(new Request(`http://localhost${path}`)); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toMatchObject({ + _tag: "AgentAuthenticationError", + code: "AUTH_REQUIRED", + retryable: false, + retryAfterMs: null, + }); + expect(body.requestId).toEqual(expect.any(String)); + } + }); + + it("rate limits unauthenticated authorization-code exchange before database access", async () => { + isRateLimited.mockResolvedValueOnce(true); + const response = await POST( + new Request("http://localhost/api/v1/auth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: "c".repeat(43), + codeVerifier: "v".repeat(43), + redirectUri: "http://127.0.0.1:49152/callback", + }), + }), + ); + const body = await response.json(); + expect(response.status).toBe(429); + expect(body).toMatchObject({ + code: "RATE_LIMITED", + retryable: true, + retryAfterMs: 60_000, + }); + }); + + it("authenticates unlocks and mutations before executing them", async () => { + const requests = [ + POST( + new Request("http://localhost/api/v1/caps/cap_synthetic/unlock", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "not-a-real-password", + }), + ), + POST( + new Request("http://localhost/api/v1/caps/cap_synthetic/comments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Synthetic comment" }), + }), + ), + PATCH( + new Request("http://localhost/api/v1/caps/cap_synthetic", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Synthetic title" }), + }), + ), + PUT( + new Request("http://localhost/api/v1/caps/cap_synthetic/password", { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: "not-a-real-password", + }), + ), + DELETE( + new Request("http://localhost/api/v1/caps/cap_synthetic", { + method: "DELETE", + }), + ), + PUT( + new Request("http://localhost/api/v1/me/image", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data: "c3ludGhldGlj", + contentType: "image/png", + fileName: "synthetic.png", + }), + }), + ), + POST( + new Request("http://localhost/api/v1/me/sign-out-all", { + method: "POST", + }), + ), + POST( + new Request("http://localhost/api/v1/me/referrals", { + method: "POST", + }), + ), + POST( + new Request("http://localhost/api/v1/organizations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Synthetic organization" }), + }), + ), + POST( + new Request( + "http://localhost/api/v1/organizations/org_synthetic/billing/checkout", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ interval: "yearly" }), + }, + ), + ), + PUT( + new Request( + "http://localhost/api/v1/organizations/org_synthetic/storage/s3", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "aws", + accessKeyId: "synthetic", + secretAccessKey: "synthetic", + endpoint: "https://s3.amazonaws.com", + bucketName: "synthetic", + region: "us-east-1", + }), + }, + ), + ), + POST( + new Request( + "http://localhost/api/v1/organizations/org_synthetic/storage/google-drive/connect", + { method: "POST" }, + ), + ), + POST( + new Request( + "http://localhost/api/v1/developer/apps/app_synthetic/credits/checkout", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amountCents: 1_000 }), + }, + ), + ), + POST( + new Request( + "http://localhost/api/v1/organizations/org_synthetic/imports/loom", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loomUrl: "https://www.loom.com/share/synthetic", + }), + }, + ), + ), + GET( + new Request( + "http://localhost/api/v1/developer/apps/app_synthetic/videos", + ), + ), + GET( + new Request( + "http://localhost/api/v1/developer/apps/app_synthetic/transactions", + ), + ), + DELETE( + new Request( + "http://localhost/api/v1/developer/apps/app_synthetic/videos/video_synthetic", + { method: "DELETE" }, + ), + ), + PATCH( + new Request( + "http://localhost/api/v1/folders/folder_synthetic/public-page", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Synthetic collection" }), + }, + ), + ), + PUT( + new Request("http://localhost/api/v1/spaces/space_synthetic/logo", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data: "c3ludGhldGlj", + contentType: "image/png", + fileName: "synthetic.png", + }), + }), + ), + ]; + for (const request of requests) { + const response = await request; + const body = await response.json(); + expect(response.status).toBe(401); + expect(body.code).toBe("AUTH_REQUIRED"); + } + }); +}); diff --git a/apps/web/__tests__/unit/agent-token-contract.test.ts b/apps/web/__tests__/unit/agent-token-contract.test.ts new file mode 100644 index 00000000000..422e671429e --- /dev/null +++ b/apps/web/__tests__/unit/agent-token-contract.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("agent token storage contract", () => { + const source = readFileSync( + join(process.cwd(), "lib/agent-token.ts"), + "utf8", + ); + + it("consumes authorization codes atomically before issuing a token", () => { + expect(source).toContain("verifyAgentCodeChallenge"); + expect(source).toContain( + "isNull(Db.agentApiAuthorizationCodes.consumedAt)", + ); + expect(source).toContain( + "gt(Db.agentApiAuthorizationCodes.expiresAt, now)", + ); + expect(source).toContain("getAffectedRows(consumed) !== 1"); + expect(source.indexOf("getAffectedRows(consumed) !== 1")).toBeLessThan( + source.indexOf("createAgentAccessToken()"), + ); + }); + + it("stores only hashes of grants and access tokens", () => { + expect(source).toMatch(/codeHash,\s+hashAgentSecret\(payload\.code\)/); + expect(source).toContain("tokenHash: hashAgentSecret(accessToken)"); + expect(source).not.toContain("accessToken: Db.agentApiKeys"); + expect(source).not.toContain("code: Db.agentApiAuthorizationCodes"); + }); + + it("never revokes a legacy desktop credential", () => { + expect(source).toContain('principal.tokenKind !== "agent"'); + expect(source).not.toContain("Db.authApiKeys"); + }); +}); diff --git a/apps/web/__tests__/unit/agent-write-contract.test.ts b/apps/web/__tests__/unit/agent-write-contract.test.ts new file mode 100644 index 00000000000..c40c702e51a --- /dev/null +++ b/apps/web/__tests__/unit/agent-write-contract.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + isAgentIdempotencyKey, + isAgentWriteAccessEnabled, +} from "@/lib/agent-write"; + +vi.mock("server-only", () => ({})); + +describe("agent write safety", () => { + it("requires a separate production write switch", () => { + expect( + isAgentWriteAccessEnabled({ + nodeEnv: "production", + enabled: undefined, + }), + ).toBe(false); + expect( + isAgentWriteAccessEnabled({ + nodeEnv: "production", + enabled: "true", + }), + ).toBe(true); + expect( + isAgentWriteAccessEnabled({ nodeEnv: "test", enabled: undefined }), + ).toBe(true); + }); + + it("accepts bounded opaque idempotency keys", () => { + expect(isAgentIdempotencyKey("0f64ed31-88d5-48bf-b364-a2af210dfa90")).toBe( + true, + ); + expect(isAgentIdempotencyKey("short")).toBe(false); + expect(isAgentIdempotencyKey("contains a secret")).toBe(false); + }); + + it("completes the mutation and idempotency record in one transaction", () => { + const source = readFileSync( + join(process.cwd(), "lib/agent-write.ts"), + "utf8", + ); + expect(source).toContain('.for("update")'); + expect(source).toContain("completeIdempotency(tx"); + expect(source).toContain("releaseIdempotency(tx"); + expect(source).toContain('state: "complete"'); + expect(source).toContain("titleManuallyEdited: true"); + expect(source).not.toContain("console.log"); + }); + + it("serializes external retries and releases failed attempts", () => { + const source = readFileSync( + join(process.cwd(), "lib/agent-write.ts"), + "utf8", + ); + expect(source).toContain('return { state: "pending" as const }'); + expect(source).toContain("Effect.tapErrorCause"); + expect(source).toContain(".delete(Db.agentApiIdempotency)"); + expect(source).toContain("record.expiresAt.getTime() <= Date.now()"); + expect(source).toContain('Schedule.exponential("25 millis")'); + }); +}); From fd60c94fd4dc88f523a07fc54ac6bd15bddca788 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 25/58] test(web): add agent API local e2e test --- .../web/__tests__/e2e/agent-local-e2e.test.ts | 1741 +++++++++++++++++ apps/web/package.json | 1 + 2 files changed, 1742 insertions(+) create mode 100644 apps/web/__tests__/e2e/agent-local-e2e.test.ts diff --git a/apps/web/__tests__/e2e/agent-local-e2e.test.ts b/apps/web/__tests__/e2e/agent-local-e2e.test.ts new file mode 100644 index 00000000000..6681b84611b --- /dev/null +++ b/apps/web/__tests__/e2e/agent-local-e2e.test.ts @@ -0,0 +1,1741 @@ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { parse } from "dotenv"; +import mysql, { type Connection, type RowDataPacket } from "mysql2/promise"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/password-cookie", () => ({ + getVerifiedPasswordHashes: vi.fn(async () => []), + setVerifiedPasswordCookie: vi.fn(async () => undefined), +})); +vi.mock("workflow/api", () => ({ + start: vi.fn(async () => ({ id: "agent-e2e-workflow" })), +})); + +const enabled = process.env.CAP_AGENT_E2E === "1"; +const agentE2e = enabled ? describe.sequential : describe.skip; +const databaseName = + process.env.CAP_AGENT_E2E_DATABASE ?? "cap_agent_e2e_d6e480cf"; +const databaseUrl = `mysql://root@127.0.0.1:3306/${databaseName}`; +if (enabled) process.env.DATABASE_URL = databaseUrl; +const token = `cap_cli_${"A".repeat(43)}`; +const memberToken = `cap_cli_${"B".repeat(43)}`; +const userId = "usr_e2e_owner"; +const memberId = "usr_e2e_member"; +const organizationMemberId = "mem_e2e_user"; +const organizationId = "org_e2e_main"; +const videoId = "cap_e2e_owned"; +const developerAppId = "dev_e2e_app"; +const operationId = "op_e2e_ready"; +const bucket = "capso"; +const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WlS8AAAAASUVORK5CYII="; +const transcriptVtt = + "WEBVTT\n\n1\n00:00:00.000 --> 00:00:01.000\nSynthetic local transcript\n"; +const scopes = [ + "caps:read", + "caps:comment", + "caps:write", + "profile:read", + "profile:write", + "caps:upload", + "caps:process", + "caps:delete", + "library:read", + "library:write", + "analytics:read", + "organizations:read", + "organizations:manage", + "organizations:members", + "notifications:read", + "notifications:write", + "integrations:read", + "integrations:write", + "billing:read", + "billing:write", + "developer:read", + "developer:write", + "developer:secrets", +]; + +type JsonObject = Record; + +type ApiResult = { + status: number; + headers: Headers; + json: unknown; + text: string; +}; + +let connection: Connection; +let server: Server; +let serverUrl: string; +let idempotencySequence = 0; +let uploadedVideoId: string | undefined; +let createdOrganizationId: string | undefined; + +const asObject = (value: unknown) => { + expect(value).toBeTypeOf("object"); + expect(value).not.toBeNull(); + return value as JsonObject; +}; + +const expectSuccess = (result: ApiResult) => { + expect(result.status, result.text).toBeGreaterThanOrEqual(200); + expect(result.status, result.text).toBeLessThan(300); + return asObject(result.json); +}; + +const nextIdempotencyKey = () => { + idempotencySequence += 1; + return `agent-e2e-${idempotencySequence}`; +}; + +const api = async ( + method: string, + path: string, + options: { + body?: unknown; + text?: string; + headers?: Record; + idempotencyKey?: string; + tokenValue?: string; + } = {}, +): Promise => { + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${options.tokenValue ?? token}`); + if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { + headers.set( + "Idempotency-Key", + options.idempotencyKey ?? nextIdempotencyKey(), + ); + headers.set("X-Cap-Confirmation", "user"); + } + let body: string | undefined; + if (options.text !== undefined) { + headers.set("Content-Type", "text/plain"); + body = options.text; + } else if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(options.body); + } + const response = await fetch(`${serverUrl}${path}`, { + method, + headers, + body, + }); + const text = await response.text(); + let json: unknown = null; + if (text && response.headers.get("content-type")?.includes("json")) { + json = JSON.parse(text); + } + return { status: response.status, headers: response.headers, json, text }; +}; + +const runCli = async ( + args: string[], + environment: Record = {}, +) => { + const child = spawn( + resolve(process.cwd(), "../../target/release/cap"), + args, + { + env: { + ...process.env, + CAP_AGENT_TOKEN: token, + CAP_SERVER_URL: serverUrl, + ...environment, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (value: string) => { + stdout += value; + }); + child.stderr.on("data", (value: string) => { + stderr += value; + }); + const status = await new Promise((resolveStatus, reject) => { + child.once("error", reject); + child.once("close", resolveStatus); + }); + expect(status, stderr).toBe(0); + return stdout.trim() ? (JSON.parse(stdout) as unknown) : null; +}; + +const seedDatabase = async () => { + const [tables] = await connection.query("SHOW TABLES"); + await connection.query("SET FOREIGN_KEY_CHECKS = 0"); + for (const row of tables) { + const tableName = String(Object.values(row)[0]); + await connection.query(`TRUNCATE TABLE \`${tableName}\``); + } + for (const tableName of [ + "agent_api_authorization_codes", + "agent_api_idempotency", + "agent_api_keys", + "agent_api_operations", + ]) { + await connection.query(`DELETE FROM \`${tableName}\``); + } + await connection.query("SET FOREIGN_KEY_CHECKS = 1"); + await connection.execute( + `INSERT INTO users + (id, name, lastName, email, stripeSubscriptionId, stripeSubscriptionStatus, activeOrganizationId, defaultOrgId, inviteQuota, preferences) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + userId, + "Agent", + "Owner", + "agent-owner@cap.local", + "sub_e2e_owner", + "active", + organizationId, + organizationId, + 10, + JSON.stringify({ + notifications: { + pauseComments: false, + pauseReplies: false, + pauseViews: false, + pauseReactions: false, + pauseAnonViews: false, + }, + }), + memberId, + "Agent", + "Member", + "agent-member@cap.local", + null, + null, + organizationId, + organizationId, + 1, + JSON.stringify({ + notifications: { + pauseComments: false, + pauseReplies: false, + pauseViews: false, + pauseReactions: false, + pauseAnonViews: false, + }, + }), + ], + ); + await connection.execute( + "INSERT INTO organizations (id, name, ownerId, settings) VALUES (?, ?, ?, ?)", + [ + organizationId, + "Agent E2E Organization", + userId, + JSON.stringify({ aiGenerationLanguage: "auto" }), + ], + ); + await connection.execute( + `INSERT INTO organization_members (id, userId, organizationId, role, hasProSeat) + VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`, + [ + "mem_e2e_owner", + userId, + organizationId, + "owner", + true, + "mem_e2e_user", + memberId, + organizationId, + "member", + false, + ], + ); + await connection.execute( + `INSERT INTO videos + (id, ownerId, orgId, name, duration, width, height, fps, metadata, public, settings, transcriptionStatus, source, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + videoId, + userId, + organizationId, + "Synthetic Agent Cap", + 1, + 1280, + 720, + 30, + JSON.stringify({ + aiTitle: "Synthetic AI title", + summary: "Synthetic local summary", + chapters: [{ title: "Opening", start: 0 }], + aiGenerationStatus: "COMPLETE", + }), + true, + JSON.stringify({ defaultPlaybackSpeed: 1 }), + "COMPLETE", + JSON.stringify({ type: "webMP4" }), + ], + ); + await connection.execute( + `INSERT INTO video_uploads + (video_id, uploaded, total, mode, phase, processing_progress, processing_message, raw_file_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + videoId, + 1024, + 1024, + "singlepart", + "complete", + 100, + "Complete", + `${userId}/${videoId}/result.mp4`, + ], + ); + await connection.execute( + `INSERT INTO comments (id, type, content, timestamp, authorId, videoId) + VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`, + [ + "cmt_e2e_text", + "text", + "Seeded comment", + 0.25, + memberId, + videoId, + "cmt_e2e_emoji", + "emoji", + "👍", + 0.5, + memberId, + videoId, + ], + ); + await connection.execute( + `INSERT INTO notifications (id, orgId, recipientId, type, data, videoId, dedupKey) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + "ntf_e2e_item", + organizationId, + userId, + "comment", + JSON.stringify({ videoId, authorId: memberId }), + videoId, + "agent-e2e-notification", + ], + ); + await connection.execute( + `INSERT INTO agent_api_keys (id, userId, tokenHash, name, scopes, expiresAt) + VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 1 DAY)), + (?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 1 DAY))`, + [ + "key_e2e_agent", + userId, + createHash("sha256").update(token).digest("hex"), + "Agent E2E", + JSON.stringify(scopes), + "key_e2e_member", + memberId, + createHash("sha256").update(memberToken).digest("hex"), + "Agent E2E Member", + JSON.stringify(["caps:read", "caps:comment", "caps:write"]), + ], + ); + await connection.execute( + `INSERT INTO developer_apps (id, ownerId, name, environment) + VALUES (?, ?, ?, ?)`, + [developerAppId, userId, "Seeded Developer App", "development"], + ); + await connection.execute( + `INSERT INTO developer_credit_accounts + (id, appId, ownerId, balanceMicroCredits, autoTopUpEnabled, autoTopUpThresholdMicroCredits, autoTopUpAmountCents) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ["dca_e2e_main", developerAppId, userId, 500_000, false, 0, 0], + ); + await connection.execute( + `INSERT INTO developer_videos (id, appId, externalUserId, name, duration, width, height, fps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "dvid_e2e_item", + developerAppId, + "synthetic-user", + "Synthetic SDK Video", + 2, + 1920, + 1080, + 30, + ], + ); + await connection.execute( + `INSERT INTO developer_credit_transactions + (id, accountId, type, amountMicroCredits, balanceAfterMicroCredits, referenceId, referenceType) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + "dtx_e2e_item", + "dca_e2e_main", + "adjustment", + 500_000, + 500_000, + "agent-e2e", + "manual", + ], + ); + await connection.execute( + `INSERT INTO agent_api_operations + (id, userId, kind, resourceId, resultResourceId, state, payload, result, completedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`, + [ + operationId, + userId, + "duplicate_cap", + videoId, + "cap_e2e_copy", + "succeeded", + JSON.stringify({ videoId }), + JSON.stringify({ id: "cap_e2e_copy" }), + ], + ); +}; + +const startApiServer = async () => { + const route = await import("@/app/api/v1/[...route]/route"); + const progressWebhook = await import( + "@/app/api/webhooks/media-server/progress/route" + ); + const handlers = new Map Promise>([ + ["GET", route.GET], + ["POST", route.POST], + ["PATCH", route.PATCH], + ["PUT", route.PUT], + ["DELETE", route.DELETE], + ["OPTIONS", route.OPTIONS], + ]); + server = createServer(async (incoming, outgoing) => { + try { + const chunks: Buffer[] = []; + for await (const chunk of incoming) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const method = incoming.method ?? "GET"; + const handler = handlers.get(method); + if (!handler) { + outgoing.writeHead(405).end(); + return; + } + const body = Buffer.concat(chunks); + const request = new Request( + `http://${incoming.headers.host}${incoming.url ?? "/"}`, + { + method, + headers: incoming.headers as HeadersInit, + body: method === "GET" || method === "HEAD" ? undefined : body, + }, + ); + const response = incoming.url?.startsWith( + "/api/webhooks/media-server/progress", + ) + ? await progressWebhook.POST( + Object.assign(request, { + nextUrl: new URL(request.url), + }) as Parameters[0], + ) + : await handler(request); + const responseBody = Buffer.from(await response.arrayBuffer()); + outgoing.writeHead(response.status, Object.fromEntries(response.headers)); + outgoing.end(responseBody); + } catch (error) { + outgoing.writeHead(500, { "Content-Type": "text/plain" }); + outgoing.end(error instanceof Error ? error.message : String(error)); + } + }); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address() as AddressInfo; + serverUrl = `http://127.0.0.1:${address.port}`; + process.env.WEB_URL = serverUrl; + process.env.NEXT_PUBLIC_WEB_URL = serverUrl; + process.env.NEXTAUTH_URL = serverUrl; + process.env.MEDIA_SERVER_WEBHOOK_URL = serverUrl; +}; + +agentE2e("Cap agent local Docker E2E", () => { + beforeAll(async () => { + const workspaceEnvironment = parse( + await readFile(resolve(process.cwd(), "../../.env")), + ); + Object.assign(process.env, { + DATABASE_URL: databaseUrl, + DATABASE_ENCRYPTION_KEY: "11".repeat(32), + NEXTAUTH_SECRET: "synthetic-agent-e2e-secret-that-is-long-enough", + NEXTAUTH_URL: "http://127.0.0.1", + WEB_URL: "http://127.0.0.1", + NEXT_PUBLIC_WEB_URL: "http://127.0.0.1", + NEXT_PUBLIC_IS_CAP: "false", + NODE_ENV: "test", + CAP_AGENT_API_WRITE_ENABLED: "true", + CAP_AWS_BUCKET: bucket, + CAP_AWS_REGION: "us-east-1", + CAP_AWS_ACCESS_KEY: "capS3root", + CAP_AWS_SECRET_KEY: "capS3root", + CAP_AWS_ENDPOINT: "http://127.0.0.1:9000", + CAP_AWS_BUCKET_URL: "http://127.0.0.1:9000/capso", + S3_PATH_STYLE: "true", + MEDIA_SERVER_URL: "http://127.0.0.1:3456", + MEDIA_SERVER_WEBHOOK_SECRET: + workspaceEnvironment.MEDIA_SERVER_WEBHOOK_SECRET ?? + "local-media-server-secret", + }); + connection = await mysql.createConnection({ + host: "127.0.0.1", + port: 3306, + user: "root", + database: databaseName, + }); + await seedDatabase(); + const s3 = new S3Client({ + endpoint: "http://127.0.0.1:9000", + region: "us-east-1", + forcePathStyle: true, + credentials: { + accessKeyId: "capS3root", + secretAccessKey: "capS3root", + }, + }); + await Promise.all([ + s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${userId}/${videoId}/transcription.vtt`, + Body: transcriptVtt, + ContentType: "text/vtt", + }), + ), + s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${userId}/${videoId}/result.mp4`, + Body: Buffer.alloc(1024, 1), + ContentType: "video/mp4", + }), + ), + ]); + await startApiServer(); + }, 60_000); + + afterAll(async () => { + if (server) { + await new Promise((resolveClose) => + server.close(() => resolveClose()), + ); + } + if (connection) await connection.end(); + }); + + it("walks every local read family through the real handler", async () => { + const reads = [ + ["/api/v1/auth/status", "authenticated"], + ["/api/v1/caps?scope=all&limit=50", "caps"], + [`/api/v1/caps/${videoId}`, "id"], + [`/api/v1/caps/${videoId}/context`, "cap"], + [`/api/v1/caps/${videoId}/status`, "id"], + [`/api/v1/caps/${videoId}/settings`, "overrides"], + [`/api/v1/caps/${videoId}/shares`, "organizations"], + ["/api/v1/me", "id"], + ["/api/v1/organizations", "organizations"], + [`/api/v1/organizations/${organizationId}`, "organization"], + [`/api/v1/organizations/${organizationId}/members`, "members"], + [`/api/v1/organizations/${organizationId}/invites`, "invites"], + [`/api/v1/organizations/${organizationId}/folders`, "folders"], + [`/api/v1/organizations/${organizationId}/spaces`, "spaces"], + [ + `/api/v1/organizations/${organizationId}/storage-integrations`, + "integrations", + ], + [`/api/v1/organizations/${organizationId}/billing`, "organizationId"], + ["/api/v1/me/notifications", "notifications"], + ["/api/v1/me/notification-preferences", "pauseComments"], + ["/api/v1/developer/apps", "apps"], + [`/api/v1/developer/apps/${developerAppId}/context`, "app"], + [`/api/v1/developer/apps/${developerAppId}/videos`, "videos"], + [`/api/v1/developer/apps/${developerAppId}/transactions`, "transactions"], + [`/api/v1/operations/${operationId}`, "id"], + ] as const; + for (const [path, expectedField] of reads) { + const result = await api("GET", path); + expect(result.status, `${path}: ${result.text}`).toBeGreaterThanOrEqual( + 200, + ); + expect(result.status, `${path}: ${result.text}`).toBeLessThan(300); + const body = asObject(result.json); + expect(body).toHaveProperty(expectedField); + } + const analytics = await api( + "GET", + `/api/v1/analytics?organizationId=${organizationId}&range=month`, + ); + expect(analytics.status).toBe(200); + expect(asObject(analytics.json).data).toBeTypeOf("object"); + + const transcriptText = await api( + "GET", + `/api/v1/caps/${videoId}/transcript?format=text`, + ); + expect(transcriptText.status).toBe(200); + expect(transcriptText.text).toContain("Synthetic local transcript"); + const transcriptJson = expectSuccess( + await api("GET", `/api/v1/caps/${videoId}/transcript?format=json`), + ); + expect(transcriptJson.cues).toHaveLength(1); + const transcriptVttResponse = await api( + "GET", + `/api/v1/caps/${videoId}/transcript?format=vtt`, + ); + expect(transcriptVttResponse.status).toBe(200); + expect(transcriptVttResponse.text).toBe(transcriptVtt); + const download = expectSuccess( + await api("GET", `/api/v1/caps/${videoId}/download`), + ); + expect(download.url).toBeTypeOf("string"); + const downloaded = await fetch(String(download.url)); + expect(downloaded.status).toBe(200); + expect((await downloaded.arrayBuffer()).byteLength).toBe(1024); + }); + + it("runs the compiled CLI against the local HTTP and database stack", async () => { + const list = asObject(await runCli(["--json", "caps", "list"])); + expect(list.caps).toHaveLength(1); + const cap = asObject(await runCli(["--json", "caps", "get", videoId])); + expect(cap.id).toBe(videoId); + const context = asObject( + await runCli(["--json", "caps", "context", videoId]), + ); + expect(asObject(context.cap).id).toBe(videoId); + const status = asObject( + await runCli(["--json", "caps", "status", videoId]), + ); + expect(status.id).toBe(videoId); + const account = asObject(await runCli(["--json", "account", "get"])); + expect(account.id).toBe(userId); + const authentication = asObject(await runCli(["--json", "auth", "status"])); + expect(authentication.authenticated).toBe(true); + expect(authentication.source).toBe("env"); + const organizations = asObject( + await runCli(["--json", "organizations", "list"]), + ); + expect(organizations.organizations).toHaveLength(1); + const folders = asObject( + await runCli(["--json", "library", "folders", "list", organizationId]), + ); + expect(folders.folders).toHaveLength(0); + const notifications = asObject( + await runCli(["--json", "notifications", "list"]), + ); + expect(notifications.notifications).toHaveLength(1); + const developers = asObject(await runCli(["--json", "developers", "list"])); + expect(developers.apps).toHaveLength(1); + expect( + asObject(await runCli(["--json", "organizations", "get", organizationId])) + .organization, + ).toBeTypeOf("object"); + expect( + asObject( + await runCli([ + "--json", + "organizations", + "storage", + "list", + organizationId, + ]), + ).integrations, + ).toHaveLength(0); + expect( + asObject(await runCli(["--json", "developers", "get", developerAppId])) + .app, + ).toBeTypeOf("object"); + expect( + asObject(await runCli(["--json", "jobs", "get", operationId])).state, + ).toBe("succeeded"); + expect( + asObject( + await runCli(["--json", "analytics", "--organization", organizationId]), + ).data, + ).toBeTypeOf("object"); + + const cliComment = asObject( + await runCli([ + "--json", + "caps", + "comments", + "add", + videoId, + "Comment through CLI", + "--timestamp-ms", + "100", + "--yes", + ]), + ); + expect(cliComment.type).toBe("text"); + expect( + asObject( + await runCli([ + "--json", + "caps", + "comments", + "reply", + videoId, + String(cliComment.id), + "Reply through CLI", + "--yes", + ]), + ).parentCommentId, + ).toBe(cliComment.id); + expect( + asObject( + await runCli([ + "--json", + "caps", + "reactions", + "add", + videoId, + "👏", + "--yes", + ]), + ).type, + ).toBe("emoji"); + expect( + asObject( + await runCli([ + "--json", + "caps", + "update", + videoId, + "--title", + "Updated through CLI", + "--yes", + ]), + ).title, + ).toBe("Updated through CLI"); + expect( + asObject( + await runCli([ + "--json", + "caps", + "sharing", + "set", + videoId, + "--private", + "--yes", + ]), + ).public, + ).toBe(false); + expect( + asObject( + await runCli([ + "--json", + "caps", + "process", + videoId, + "--target", + "transcript", + "--yes", + ]), + ).requested, + ).toBe("transcript"); + expect( + asObject( + await runCli([ + "--json", + "notifications", + "preferences", + "--pause-views", + "true", + "--yes", + ]), + ).pauseViews, + ).toBe(true); + + const outputDirectory = await mkdtemp( + resolve(tmpdir(), "cap-agent-cli-e2e-"), + ); + try { + const transcriptPath = resolve(outputDirectory, "transcript.vtt"); + const transcriptResult = asObject( + await runCli([ + "--json", + "caps", + "transcript", + videoId, + "--format", + "vtt", + "--output", + transcriptPath, + ]), + ); + expect(transcriptResult.bytes).toBe(Buffer.byteLength(transcriptVtt)); + expect(await readFile(transcriptPath, "utf8")).toBe(transcriptVtt); + + const downloadPath = resolve(outputDirectory, "download.mp4"); + const downloadResult = asObject( + await runCli([ + "--json", + "caps", + "download", + videoId, + "--output", + downloadPath, + ]), + ); + expect(downloadResult.bytes).toBe(1024); + expect((await stat(downloadPath)).size).toBe(1024); + expect( + await runCli([ + "--json", + "caps", + "wait", + videoId, + "--for", + "all", + "--timeout", + "1", + ]), + ).not.toBeNull(); + } finally { + await rm(outputDirectory, { recursive: true, force: true }); + } + }); + + it("uploads and processes a real MP4 through CLI, MinIO, and the media server", async () => { + const fixture = resolve( + process.cwd(), + "../media-server/src/__tests__/fixtures/test-no-audio.mp4", + ); + const uploaded = asObject( + await runCli([ + "--json", + "upload", + fixture, + "--name", + "Agent E2E Uploaded Cap", + ]), + ); + uploadedVideoId = String(uploaded.id); + expect(uploaded.type).toBe("uploaded"); + expect(uploaded.link).toBe(`${serverUrl}/s/${uploadedVideoId}`); + + const [uploads] = await connection.execute( + "SELECT raw_file_key AS rawFileKey, phase FROM video_uploads WHERE video_id = ?", + [uploadedVideoId], + ); + expect(uploads[0]?.phase).toBe("processing"); + const rawFileKey = String(uploads[0]?.rawFileKey); + expect(rawFileKey).toBe(`${userId}/${uploadedVideoId}/raw-upload.mp4`); + + const { processVideoWorkflow } = await import("@/workflows/process-video"); + const result = await processVideoWorkflow({ + videoId: uploadedVideoId, + userId, + rawFileKey, + bucketId: null, + }); + expect(result.success).toBe(true); + expect(result.metadata?.width).toBeGreaterThan(0); + expect(result.metadata?.height).toBeGreaterThan(0); + + const cap = asObject( + await runCli(["--json", "caps", "get", uploadedVideoId]), + ); + expect(cap.title).toBe("Agent E2E Uploaded Cap"); + const outputDirectory = await mkdtemp( + resolve(tmpdir(), "cap-agent-upload-e2e-"), + ); + try { + const downloadPath = resolve(outputDirectory, "processed.mp4"); + const downloaded = asObject( + await runCli([ + "--json", + "caps", + "download", + uploadedVideoId, + "--output", + downloadPath, + ]), + ); + expect(Number(downloaded.bytes)).toBeGreaterThan(0); + expect((await stat(downloadPath)).size).toBe(Number(downloaded.bytes)); + } finally { + await rm(outputDirectory, { recursive: true, force: true }); + } + }, 60_000); + + it("installs the skill and MCP config only into an isolated selected agent", async () => { + const home = await mkdtemp(resolve(tmpdir(), "cap-agent-install-e2e-")); + const codexHome = resolve(home, ".codex"); + try { + const preview = asObject( + await runCli( + [ + "--json", + "agents", + "install", + "--target", + "codex", + "--component", + "all", + "--dry-run", + "--yes", + ], + { HOME: home, CODEX_HOME: codexHome }, + ), + ); + expect(preview.applied).toBe(false); + expect(preview.changes).toHaveLength(2); + + const installed = asObject( + await runCli( + [ + "--json", + "agents", + "install", + "--target", + "codex", + "--component", + "all", + "--yes", + ], + { HOME: home, CODEX_HOME: codexHome }, + ), + ); + expect(installed.applied).toBe(true); + expect( + await readFile(resolve(codexHome, "skills/cap/SKILL.md"), "utf8"), + ).toContain("cap guide --json"); + expect( + await readFile(resolve(codexHome, "config.toml"), "utf8"), + ).toContain('command = "cap"'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it("persists confirmed mutations, permissions, idempotency, and secure input", async () => { + const updatedProfile = expectSuccess( + await api("PATCH", "/api/v1/me", { + body: { name: "Updated Agent", lastName: "E2E" }, + }), + ); + expect(updatedProfile.name).toBe("Updated Agent"); + + const preferences = expectSuccess( + await api("PATCH", "/api/v1/me/notification-preferences", { + body: { pauseComments: true, pauseAnonymousViews: true }, + }), + ); + expect(preferences.pauseComments).toBe(true); + expectSuccess( + await api("POST", "/api/v1/me/notifications/read", { + body: { all: true }, + }), + ); + + const commentKey = nextIdempotencyKey(); + const firstComment = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/comments`, { + body: { content: "Agent E2E comment", timestampMs: 200 }, + idempotencyKey: commentKey, + }), + ); + const replayedComment = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/comments`, { + body: { content: "Agent E2E comment", timestampMs: 200 }, + idempotencyKey: commentKey, + }), + ); + expect(replayedComment.id).toBe(firstComment.id); + const missingReplyKey = nextIdempotencyKey(); + for (let attempt = 0; attempt < 2; attempt += 1) { + const missingReply = await api( + "POST", + `/api/v1/caps/${videoId}/comments/missingcomment1/replies`, + { + body: { content: "Missing parent" }, + idempotencyKey: missingReplyKey, + }, + ); + expect(missingReply.status).toBe(404); + expect(asObject(missingReply.json).code).toBe("NOT_FOUND"); + } + const reply = expectSuccess( + await api( + "POST", + `/api/v1/caps/${videoId}/comments/${String(firstComment.id)}/replies`, + { body: { content: "Agent E2E reply" } }, + ), + ); + const reaction = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/reactions`, { + body: { content: "🚀", timestampMs: 400 }, + }), + ); + expect(reply.parentCommentId).toBe(firstComment.id); + expect(reaction.type).toBe("emoji"); + + const updatedCap = expectSuccess( + await api("PATCH", `/api/v1/caps/${videoId}`, { + body: { title: "Updated Agent Cap", public: false }, + }), + ); + expect(updatedCap.title).toBe("Updated Agent Cap"); + expect(updatedCap.public).toBe(false); + expectSuccess( + await api("PATCH", `/api/v1/caps/${videoId}/settings`, { + body: { defaultPlaybackSpeed: 1.25, disableReactions: false }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/caps/${videoId}/date`, { + body: { createdAt: "2026-07-18T12:00:00.000Z" }, + }), + ); + + const transcript = expectSuccess( + await api("GET", `/api/v1/caps/${videoId}/transcript?format=json`), + ); + const replaced = expectSuccess( + await api("PUT", `/api/v1/caps/${videoId}/transcript`, { + body: { + expectedRevision: transcript.revision, + cues: [{ startMs: 0, endMs: 1000, text: "Replaced transcript" }], + }, + }), + ); + expect(replaced.cueCount).toBe(1); + + expectSuccess( + await api("PUT", `/api/v1/caps/${videoId}/password`, { + text: "local-agent-password", + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/caps/${videoId}`, { + body: { public: true }, + }), + ); + const locked = await api("GET", `/api/v1/caps/${videoId}/context`, { + tokenValue: memberToken, + }); + expect(locked.status).toBe(403); + expect(asObject(locked.json).code).toBe("PASSWORD_REQUIRED"); + const unlock = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/unlock`, { + text: "local-agent-password", + tokenValue: memberToken, + }), + ); + const unlocked = await api("GET", `/api/v1/caps/${videoId}/context`, { + headers: { "X-Cap-Access-Grant": String(unlock.accessGrant) }, + tokenValue: memberToken, + }); + expect(unlocked.status).toBe(200); + expectSuccess( + await api("PUT", `/api/v1/caps/${videoId}/password`, { text: "" }), + ); + + expectSuccess( + await api( + "DELETE", + `/api/v1/caps/${videoId}/comments/${String(reply.id)}`, + ), + ); + expectSuccess( + await api( + "DELETE", + `/api/v1/caps/${videoId}/comments/${String(reaction.id)}`, + ), + ); + }); + + it("walks organization, library, storage, image, and developer management", async () => { + const createdOrganization = expectSuccess( + await api("POST", "/api/v1/organizations", { + body: { name: "Created by Agent E2E" }, + }), + ); + expect(createdOrganization.action).toBe("created"); + createdOrganizationId = String(asObject(createdOrganization.resource).id); + + expectSuccess( + await api("PATCH", `/api/v1/organizations/${organizationId}`, { + body: { + name: "Updated E2E Organization", + allowedEmailDomain: "cap.local", + }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/organizations/${organizationId}/settings`, { + body: { + disableComments: false, + aiGenerationLanguage: "en", + defaultPlaybackSpeed: 1.5, + }, + }), + ); + + const invite = expectSuccess( + await api("POST", `/api/v1/organizations/${organizationId}/invites`, { + body: { + email: "invitee@cap.local", + role: "member", + sendEmail: false, + }, + }), + ); + expectSuccess( + await api( + "DELETE", + `/api/v1/organizations/${organizationId}/invites/${String(asObject(invite.invite).id)}`, + ), + ); + expectSuccess( + await api( + "PATCH", + `/api/v1/organizations/${organizationId}/members/${organizationMemberId}`, + { body: { role: "admin" } }, + ), + ); + expectSuccess( + await api( + "PATCH", + `/api/v1/organizations/${organizationId}/members/${organizationMemberId}/seat`, + { body: { enabled: true } }, + ), + ); + + const folder = expectSuccess( + await api("POST", `/api/v1/organizations/${organizationId}/folders`, { + body: { + name: "Agent Folder", + color: "blue", + spaceId: organizationId, + }, + }), + ); + const folderId = String(asObject(folder.resource).id); + const space = expectSuccess( + await api("POST", `/api/v1/organizations/${organizationId}/spaces`, { + body: { + name: "Agent Space", + description: "Synthetic E2E space", + privacy: "Private", + }, + }), + ); + const spaceId = String(asObject(space.resource).id); + expectSuccess( + await api("PATCH", `/api/v1/folders/${folderId}`, { + body: { name: "Updated Agent Folder", color: "yellow" }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/spaces/${spaceId}`, { + body: { name: "Updated Agent Space", privacy: "Public" }, + }), + ); + expectSuccess( + await api("POST", `/api/v1/spaces/${spaceId}/members`, { + body: { userId: memberId, role: "member" }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/spaces/${spaceId}/members/${memberId}`, { + body: { role: "admin" }, + }), + ); + const spaceMembers = expectSuccess( + await api("GET", `/api/v1/spaces/${spaceId}/members`), + ); + expect(spaceMembers.members).toHaveLength(2); + expectSuccess( + await api("PATCH", `/api/v1/folders/${folderId}/public-page`, { + body: { + public: true, + title: "Agent Folder Collection", + layout: "grid", + gridColumns: 3, + }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/spaces/${spaceId}/public-page`, { + body: { + public: true, + title: "Agent Space Collection", + layout: "list", + }, + }), + ); + + const moveBeforeShare = await api( + "PATCH", + `/api/v1/caps/${videoId}/location`, + { + body: { + container: "space", + organizationId, + spaceId, + folderId: null, + }, + }, + ); + expect(moveBeforeShare.status).toBe(404); + expect(asObject(moveBeforeShare.json).code).toBe("NOT_FOUND"); + expectSuccess( + await api( + "PUT", + `/api/v1/caps/${videoId}/shares/organizations/${organizationId}`, + { body: { folderId } }, + ), + ); + expectSuccess( + await api("PUT", `/api/v1/caps/${videoId}/shares/spaces/${spaceId}`, { + body: { folderId: null }, + }), + ); + expectSuccess( + await api("PATCH", `/api/v1/caps/${videoId}/location`, { + body: { + container: "space", + organizationId, + spaceId, + folderId: null, + }, + }), + ); + + const image = { + data: tinyPng, + contentType: "image/png", + fileName: "agent-e2e.png", + }; + for (const path of [ + "/api/v1/me/image", + `/api/v1/organizations/${organizationId}/icon`, + `/api/v1/organizations/${organizationId}/shareable-link-icon`, + `/api/v1/folders/${folderId}/logo`, + `/api/v1/spaces/${spaceId}/logo`, + ]) { + expectSuccess(await api("PUT", path, { body: image })); + expectSuccess(await api("DELETE", path)); + } + + const s3Config = { + provider: "minio", + accessKeyId: "capS3root", + secretAccessKey: "capS3root", + endpoint: "http://127.0.0.1:9000", + bucketName: bucket, + region: "us-east-1", + }; + expectSuccess( + await api( + "POST", + `/api/v1/organizations/${organizationId}/storage/s3/test`, + { + body: s3Config, + }, + ), + ); + expectSuccess( + await api("PUT", `/api/v1/organizations/${organizationId}/storage/s3`, { + body: s3Config, + }), + ); + expectSuccess( + await api( + "PATCH", + `/api/v1/organizations/${organizationId}/storage/provider`, + { + body: { provider: "s3" }, + }, + ), + ); + expectSuccess( + await api("DELETE", `/api/v1/organizations/${organizationId}/storage/s3`), + ); + for (const result of [ + await api( + "GET", + `/api/v1/organizations/${organizationId}/storage/google-drive/folders`, + ), + await api( + "PUT", + `/api/v1/organizations/${organizationId}/storage/google-drive/location`, + { body: { folderId: "root" } }, + ), + await api( + "DELETE", + `/api/v1/organizations/${organizationId}/storage/google-drive`, + ), + ]) { + expect(result.status).toBeGreaterThanOrEqual(400); + expect(asObject(result.json).code).toBeTypeOf("string"); + } + + const developer = expectSuccess( + await api("POST", "/api/v1/developer/apps", { + body: { name: "Agent Created App", environment: "development" }, + }), + ); + const createdAppId = String(developer.appId); + expect(developer.publicKey).toMatch(/^cpk_/); + expect(developer.secretKey).toMatch(/^csk_/); + expectSuccess( + await api("PATCH", `/api/v1/developer/apps/${createdAppId}`, { + body: { name: "Updated Agent App", environment: "production" }, + }), + ); + const domain = expectSuccess( + await api("POST", `/api/v1/developer/apps/${createdAppId}/domains`, { + body: { domain: "https://agent-e2e.cap.local" }, + }), + ); + expectSuccess( + await api( + "DELETE", + `/api/v1/developer/apps/${createdAppId}/domains/${String(asObject(domain.resource).id)}`, + ), + ); + const rotated = expectSuccess( + await api("POST", `/api/v1/developer/apps/${createdAppId}/keys/rotate`), + ); + expect(rotated.secretKey).toMatch(/^csk_/); + expectSuccess( + await api("PATCH", `/api/v1/developer/apps/${createdAppId}/auto-top-up`, { + body: { + enabled: true, + thresholdMicroCredits: 100_000, + amountCents: 1_000, + }, + }), + ); + const creditCheckout = await api( + "POST", + `/api/v1/developer/apps/${createdAppId}/credits/checkout`, + { body: { amountCents: 500 } }, + ); + expect(creditCheckout.status).toBeGreaterThanOrEqual(400); + expect(asObject(creditCheckout.json).code).toBeTypeOf("string"); + expectSuccess( + await api("DELETE", `/api/v1/developer/apps/${createdAppId}`), + ); + expectSuccess( + await api( + "DELETE", + `/api/v1/developer/apps/${developerAppId}/videos/dvid_e2e_item`, + ), + ); + + expectSuccess( + await api("DELETE", `/api/v1/caps/${videoId}/shares/spaces/${spaceId}`), + ); + expectSuccess( + await api( + "DELETE", + `/api/v1/caps/${videoId}/shares/organizations/${organizationId}`, + ), + ); + expectSuccess( + await api("DELETE", `/api/v1/spaces/${spaceId}/members/${memberId}`), + ); + expectSuccess(await api("DELETE", `/api/v1/folders/${folderId}`)); + expectSuccess(await api("DELETE", `/api/v1/spaces/${spaceId}`)); + }); + + it("executes durable duplicate, delete, domain, and organization workflows", async () => { + const processResult = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/process`, { + body: { target: "transcript", retry: false }, + }), + ); + expect(processResult.requested).toBe("transcript"); + + const { agentCapOperationWorkflow } = await import( + "@/workflows/agent-cap-operation" + ); + const duplicateOperation = expectSuccess( + await api("POST", `/api/v1/caps/${videoId}/duplicate`), + ); + expect(duplicateOperation.state).toBe("queued"); + await agentCapOperationWorkflow({ + operationId: String(duplicateOperation.id), + }); + const completedDuplicate = expectSuccess( + await api("GET", `/api/v1/operations/${String(duplicateOperation.id)}`), + ); + expect(completedDuplicate.state).toBe("succeeded"); + const duplicateId = String(completedDuplicate.resultResourceId); + expectSuccess(await api("GET", `/api/v1/caps/${duplicateId}`)); + const duplicateDownload = expectSuccess( + await api("GET", `/api/v1/caps/${duplicateId}/download`), + ); + expect((await fetch(String(duplicateDownload.url))).status).toBe(200); + + const deleteDuplicate = expectSuccess( + await api("DELETE", `/api/v1/caps/${duplicateId}`), + ); + await agentCapOperationWorkflow({ + operationId: String(deleteDuplicate.id), + }); + const deletedDuplicate = expectSuccess( + await api("GET", `/api/v1/operations/${String(deleteDuplicate.id)}`), + ); + expect(deletedDuplicate.state).toBe("succeeded"); + expect((await api("GET", `/api/v1/caps/${duplicateId}`)).status).toBe(404); + + if (!uploadedVideoId) throw new Error("Upload E2E did not produce a Cap"); + const deleteUpload = expectSuccess( + await api("DELETE", `/api/v1/caps/${uploadedVideoId}`), + ); + await agentCapOperationWorkflow({ + operationId: String(deleteUpload.id), + }); + expect( + asObject( + expectSuccess( + await api("GET", `/api/v1/operations/${String(deleteUpload.id)}`), + ).result, + ).deleted, + ).toBe(true); + + const invalidDomain = await api( + "PUT", + `/api/v1/organizations/${organizationId}/domain`, + { body: { domain: "invalid domain" } }, + ); + expect(invalidDomain.status).toBe(400); + expect(asObject(invalidDomain.json).code).toBe("INVALID_REQUEST"); + for (const [method, path] of [ + ["DELETE", `/api/v1/organizations/${organizationId}/domain`], + ["POST", `/api/v1/organizations/${organizationId}/domain/verify`], + ] as const) { + const operation = expectSuccess(await api(method, path)); + await agentCapOperationWorkflow({ operationId: String(operation.id) }); + const completed = expectSuccess( + await api("GET", `/api/v1/operations/${String(operation.id)}`), + ); + expect(completed.state).toBe("succeeded"); + } + + if (!createdOrganizationId) { + throw new Error( + "Organization management E2E did not create an organization", + ); + } + const deleteOrganization = expectSuccess( + await api("DELETE", `/api/v1/organizations/${createdOrganizationId}`), + ); + await agentCapOperationWorkflow({ + operationId: String(deleteOrganization.id), + }); + const completedOrganizationDelete = expectSuccess( + await api("GET", `/api/v1/operations/${String(deleteOrganization.id)}`), + ); + expect(completedOrganizationDelete.state).toBe("succeeded"); + expect( + (await api("GET", `/api/v1/organizations/${createdOrganizationId}`)) + .status, + ).toBe(403); + + expectSuccess( + await api( + "DELETE", + `/api/v1/organizations/${organizationId}/members/${organizationMemberId}`, + ), + ); + }, 60_000); + + it("exercises token exchange, revocation, and external dependency boundaries", async () => { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const code = randomBytes(32).toString("base64url"); + const redirectUri = "http://127.0.0.1:45678/callback"; + await connection.execute( + `INSERT INTO agent_api_authorization_codes + (id, userId, codeHash, codeChallenge, redirectUri, scopes, expiresAt) + VALUES (?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE))`, + [ + "cod_e2e_login", + userId, + createHash("sha256").update(code).digest("hex"), + challenge, + redirectUri, + JSON.stringify(["caps:read", "caps:comment", "caps:write"]), + ], + ); + const exchanged = expectSuccess( + await api("POST", "/api/v1/auth/token", { + body: { code, codeVerifier: verifier, redirectUri }, + tokenValue: "unused", + }), + ); + const issuedToken = String(exchanged.accessToken); + expect(issuedToken).toMatch(/^cap_cli_/); + const issuedRead = await api("GET", "/api/v1/caps", { + tokenValue: issuedToken, + }); + expect(issuedRead.status).toBe(200); + const revoked = expectSuccess( + await api("POST", "/api/v1/auth/revoke", { tokenValue: issuedToken }), + ); + expect(revoked.revoked).toBe(true); + const revokedRead = await api("GET", "/api/v1/caps", { + tokenValue: issuedToken, + }); + expect(revokedRead.status).toBe(401); + expect(asObject(revokedRead.json).code).toBe("TOKEN_EXPIRED"); + + const guardedCalls = [ + api("POST", `/api/v1/organizations/${organizationId}/billing/checkout`, { + body: { interval: "monthly" }, + }), + api("POST", `/api/v1/organizations/${organizationId}/billing/portal`), + api( + "POST", + `/api/v1/organizations/${organizationId}/storage/google-drive/connect`, + ), + api("POST", "/api/v1/me/referrals"), + api("POST", `/api/v1/organizations/${organizationId}/imports/loom`, { + body: { loomUrl: "not-a-loom-url" }, + }), + ]; + for (const result of await Promise.all(guardedCalls)) { + expect(result.status).toBeGreaterThanOrEqual(400); + expect(asObject(result.json).code).toBeTypeOf("string"); + } + }); + + it("runs MCP inventory, resources, reads, and confirmed writes through stdio", async () => { + const child = spawn( + resolve(process.cwd(), "../../target/release/cap"), + ["mcp", "serve"], + { + env: { + ...process.env, + CAP_AGENT_TOKEN: token, + CAP_SERVER_URL: serverUrl, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const lines = createInterface({ input: child.stdout }); + const iterator = lines[Symbol.asyncIterator](); + const send = (value: unknown) => { + child.stdin.write(`${JSON.stringify(value)}\n`); + }; + const readMessage = async () => + asObject(JSON.parse(String((await iterator.next()).value)) as unknown); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "cap-agent-e2e", version: "1" }, + }, + }); + const initialized = await readMessage(); + expect(initialized.id).toBe(1); + send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }); + send({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const listedTools = await readMessage(); + expect(listedTools.id).toBe(2); + const tools = asObject(listedTools.result).tools; + expect(tools).toHaveLength(76); + expect( + new Set((tools as unknown[]).map((tool) => String(asObject(tool).name))) + .size, + ).toBe(76); + send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "caps_get", arguments: { cap: videoId } }, + }); + const read = await readMessage(); + expect(read.id).toBe(3); + expect(JSON.stringify(read)).toContain(videoId); + send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "caps_update_title", + arguments: { + cap: videoId, + title: "Updated through MCP", + confirmed: true, + }, + }, + }); + const write = await readMessage(); + expect(write.id).toBe(4); + expect(JSON.stringify(write)).toContain("Updated through MCP"); + send({ + jsonrpc: "2.0", + id: 5, + method: "resources/templates/list", + params: {}, + }); + const templates = await readMessage(); + expect(templates.id).toBe(5); + expect(asObject(templates.result).resourceTemplates).toHaveLength(3); + send({ + jsonrpc: "2.0", + id: 6, + method: "resources/read", + params: { uri: `cap://caps/${videoId}/transcript` }, + }); + const resource = await readMessage(); + expect(resource.id).toBe(6); + expect(JSON.stringify(resource)).toContain("Replaced transcript"); + child.kill(); + await new Promise((resolveExit) => + child.once("close", () => resolveExit()), + ); + }); + + it("verifies database state and that reads do not start work", async () => { + const [videoRows] = await connection.execute( + "SELECT name, transcriptionStatus, metadata FROM videos WHERE id = ?", + [videoId], + ); + expect(videoRows[0]?.name).toBe("Updated through MCP"); + expect(videoRows[0]?.transcriptionStatus).toBe("COMPLETE"); + const metadata = + typeof videoRows[0]?.metadata === "string" + ? JSON.parse(videoRows[0].metadata) + : videoRows[0]?.metadata; + expect(metadata.aiGenerationStatus).toBe("COMPLETE"); + const [comments] = await connection.execute( + "SELECT COUNT(*) AS count FROM comments WHERE videoId = ? AND content = ?", + [videoId, "Agent E2E comment"], + ); + expect(Number(comments[0]?.count)).toBe(1); + const [idempotency] = await connection.execute( + "SELECT COUNT(*) AS count FROM agent_api_idempotency WHERE state = 'complete'", + ); + expect(Number(idempotency[0]?.count)).toBeGreaterThan(10); + }); + + it("deletes only expired agent records in bounded cleanup batches", async () => { + await connection.execute( + `INSERT INTO agent_api_authorization_codes + (id, userId, codeHash, codeChallenge, redirectUri, scopes, expiresAt) + VALUES + ('cod_e2e_expired', ?, ?, ?, 'http://127.0.0.1:45678/callback', ?, DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('cod_e2e_live', ?, ?, ?, 'http://127.0.0.1:45678/callback', ?, DATE_ADD(NOW(), INTERVAL 1 DAY))`, + [ + userId, + "c".repeat(64), + "d".repeat(43), + JSON.stringify(["caps:read"]), + userId, + "e".repeat(64), + "f".repeat(43), + JSON.stringify(["caps:read"]), + ], + ); + await connection.execute( + `INSERT INTO agent_api_idempotency + (id, userId, operation, keyHash, requestHash, state, expiresAt) + VALUES + ('ide_e2e_expired', ?, 'cleanup_test', ?, ?, 'complete', DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('ide_e2e_live', ?, 'cleanup_test', ?, ?, 'complete', DATE_ADD(NOW(), INTERVAL 1 DAY))`, + [ + userId, + "1".repeat(64), + "2".repeat(64), + userId, + "3".repeat(64), + "4".repeat(64), + ], + ); + await connection.execute( + `INSERT INTO agent_api_keys + (id, userId, tokenHash, name, scopes, expiresAt) + VALUES + ('key_e2e_old', ?, ?, 'Expired cleanup key', ?, DATE_SUB(NOW(), INTERVAL 31 DAY)), + ('key_e2e_live2', ?, ?, 'Live cleanup key', ?, DATE_ADD(NOW(), INTERVAL 1 DAY))`, + [ + userId, + "5".repeat(64), + JSON.stringify(["caps:read"]), + userId, + "6".repeat(64), + JSON.stringify(["caps:read"]), + ], + ); + process.env.CRON_SECRET = "synthetic-agent-cleanup-secret"; + const { GET: cleanupAgentApi } = await import( + "@/app/api/cron/cleanup-agent-api/route" + ); + const unauthorized = await cleanupAgentApi( + new Request("http://localhost/api/cron/cleanup-agent-api"), + ); + expect(unauthorized.status).toBe(401); + const cleanupResponse = await cleanupAgentApi( + new Request("http://localhost/api/cron/cleanup-agent-api", { + headers: { + Authorization: "Bearer synthetic-agent-cleanup-secret", + }, + }), + ); + delete process.env.CRON_SECRET; + expect(cleanupResponse.status).toBe(200); + const cleanupBody = asObject(await cleanupResponse.json()); + const deleted = asObject(cleanupBody.deleted); + expect(Number(deleted.authorizationCodes)).toBeGreaterThanOrEqual(1); + expect(Number(deleted.idempotencyRecords)).toBeGreaterThanOrEqual(1); + expect(Number(deleted.accessTokens)).toBeGreaterThanOrEqual(1); + const [remaining] = await connection.execute( + `SELECT + (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_live') AS liveCodes, + (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_live') AS liveIdempotency, + (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_live2') AS liveKeys, + (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_expired') AS expiredCodes, + (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_expired') AS expiredIdempotency, + (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_old') AS expiredKeys`, + ); + expect(Number(remaining[0]?.liveCodes)).toBe(1); + expect(Number(remaining[0]?.liveIdempotency)).toBe(1); + expect(Number(remaining[0]?.liveKeys)).toBe(1); + expect(Number(remaining[0]?.expiredCodes)).toBe(0); + expect(Number(remaining[0]?.expiredIdempotency)).toBe(0); + expect(Number(remaining[0]?.expiredKeys)).toBe(0); + }); + + it("revokes every session only as the final destructive account action", async () => { + const signedOut = expectSuccess( + await api("POST", "/api/v1/me/sign-out-all"), + ); + expect(signedOut.action).toBe("revoked"); + const revoked = await api("GET", "/api/v1/me"); + expect(revoked.status).toBe(401); + expect(asObject(revoked.json).code).toBe("TOKEN_EXPIRED"); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 31f265993aa..2e406136b4a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "start": "cross-env NODE_OPTIONS=--disable-warning=DEP0169 next start", "compress-images": "bash tools/compress-images.sh", "test": "vitest run", + "test:agent-e2e": "CAP_AGENT_E2E=1 vitest run __tests__/e2e/agent-local-e2e.test.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui" From 425c47a05c7fe50b3079f654459cda46be87a02a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 26/58] build(cli): add agent API CLI dependencies --- apps/cli/Cargo.toml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 082df3cb906..e8fb5b520af 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -19,7 +19,7 @@ cap-cli-install = { path = "../../crates/cli-install" } scap-targets = { path = "../../crates/scap-targets" } serde = { workspace = true } serde_json = "1.0.133" -tokio = { workspace = true, features = ["signal"] } +tokio = { workspace = true, features = ["io-util", "net", "signal"] } tokio-util = { version = "0.7", features = ["io"] } uuid = { version = "1.11.1", features = ["v4"] } ffmpeg = { workspace = true } @@ -30,6 +30,14 @@ futures = { workspace = true } dirs = "6.0.0" image = "0.25.2" chrono = "0.4.31" +base64 = "0.22.1" +keyring = "4.1.5" +open = "5.3.6" +rmcp = { version = "2.2.0", features = ["transport-io"] } +rpassword = "7.5.4" +sha2 = "0.10.9" +toml_edit = "0.23.7" +url = "2.5.7" tracing.workspace = true tracing-subscriber = "0.3.19" cpal = { workspace = true } @@ -43,6 +51,7 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] windows = { workspace = true, features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", "Win32_System_Threading", ] } From e5097d837c02b51f82a62950b4de23b676407077 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 27/58] feat(cli): add atomic write and confirmation helpers --- apps/cli/src/atomic.rs | 51 ++++++++++++++++++++++++++++++++++++ apps/cli/src/confirmation.rs | 25 ++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/cli/src/atomic.rs create mode 100644 apps/cli/src/confirmation.rs diff --git a/apps/cli/src/atomic.rs b/apps/cli/src/atomic.rs new file mode 100644 index 00000000000..98c0f1ffc74 --- /dev/null +++ b/apps/cli/src/atomic.rs @@ -0,0 +1,51 @@ +use std::{io, path::Path}; + +#[cfg(not(windows))] +pub fn replace(temporary: &Path, destination: &Path) -> io::Result<()> { + std::fs::rename(temporary, destination) +} + +#[cfg(windows)] +pub fn replace(temporary: &Path, destination: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + use windows::core::PCWSTR; + + let temporary = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(temporary.as_ptr()), + PCWSTR(destination.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .map_err(|error| io::Error::other(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replaces_an_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let destination = directory.path().join("destination"); + let temporary = directory.path().join("temporary"); + std::fs::write(&destination, "old").unwrap(); + std::fs::write(&temporary, "new").unwrap(); + replace(&temporary, &destination).unwrap(); + assert_eq!(std::fs::read_to_string(destination).unwrap(), "new"); + assert!(!temporary.exists()); + } +} diff --git a/apps/cli/src/confirmation.rs b/apps/cli/src/confirmation.rs new file mode 100644 index 00000000000..be89249ba6b --- /dev/null +++ b/apps/cli/src/confirmation.rs @@ -0,0 +1,25 @@ +use std::io::{IsTerminal, Write}; + +pub fn require(yes: bool, action: &str) -> Result<(), String> { + if yes { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + return Err(format!( + "{action} requires --yes when stdin is not interactive" + )); + } + eprint!("{action}? [y/N] "); + std::io::stderr() + .flush() + .map_err(|error| error.to_string())?; + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|error| error.to_string())?; + if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + Ok(()) + } else { + Err("Cancelled".to_string()) + } +} From 19e90725314936e0f2d233bf1717d6f4361a3911 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 28/58] feat(cli): add agent OAuth authentication module --- apps/cli/src/agent_auth.rs | 483 +++++++++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 apps/cli/src/agent_auth.rs diff --git a/apps/cli/src/agent_auth.rs b/apps/cli/src/agent_auth.rs new file mode 100644 index 00000000000..b425f8ceaaa --- /dev/null +++ b/apps/cli/src/agent_auth.rs @@ -0,0 +1,483 @@ +use std::{io::IsTerminal, time::Duration}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use clap::{Args, ValueEnum}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use url::Url; + +use crate::{ + OutputFormat, + credentials::{self, AgentCredentialSource, StoredAgentCredential}, + resolve_format, write_json, +}; + +#[derive(Args)] +pub struct LoginArgs { + #[arg(long, value_enum, default_value_t = LoginProfile::Creator)] + profile: LoginProfile, + #[arg(long)] + no_open: bool, + #[arg( + long, + help = "Allow a permission-restricted file fallback on macOS or Linux when the OS credential store is unavailable" + )] + allow_file_credential: bool, + #[arg(long, default_value_t = 300)] + timeout: u64, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum LoginProfile { + Creator, + Admin, + Full, +} + +impl LoginProfile { + const fn scopes(self) -> &'static str { + const CREATOR: &str = "caps:read caps:comment caps:write profile:read profile:write caps:upload caps:process caps:delete library:read library:write analytics:read notifications:read notifications:write"; + const ADMIN: &str = "caps:read caps:comment caps:write profile:read profile:write caps:upload caps:process caps:delete library:read library:write analytics:read organizations:read organizations:manage organizations:members notifications:read notifications:write integrations:read integrations:write billing:read billing:write"; + const FULL: &str = "caps:read caps:comment caps:write profile:read profile:write caps:upload caps:process caps:delete library:read library:write analytics:read organizations:read organizations:manage organizations:members notifications:read notifications:write integrations:read integrations:write billing:read billing:write developer:read developer:write developer:secrets"; + match self { + Self::Creator => CREATOR, + Self::Admin => ADMIN, + Self::Full => FULL, + } + } +} + +#[derive(Args)] +pub struct LogoutArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LoginResult { + authenticated: bool, + server: String, + expires_at: String, + scopes: Vec, + storage: credentials::AgentCredentialStorage, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LogoutResult { + authenticated: bool, + revoked: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TokenRequest<'a> { + code: &'a str, + code_verifier: &'a str, + redirect_uri: &'a str, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenResponse { + access_token: String, + expires_at: String, + scopes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RevokeResponse { + revoked: bool, +} + +fn random_base64url() -> String { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let mut bytes = [0_u8; 32]; + bytes[..16].copy_from_slice(first.as_bytes()); + bytes[16..].copy_from_slice(second.as_bytes()); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn code_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn api_error(status: reqwest::StatusCode, body: &str) -> String { + let value = serde_json::from_str::(body).ok(); + let code = value + .as_ref() + .and_then(|value| value.get("code")) + .and_then(serde_json::Value::as_str); + let message = value + .as_ref() + .and_then(|value| value.get("message")) + .and_then(serde_json::Value::as_str); + match (code, message) { + (Some(code), Some(message)) => format!("{code}: {message}"), + _ => format!("Cap returned HTTP {status}"), + } +} + +fn auth_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| "Failed to initialize secure authentication".to_string()) +} + +const fn is_agent_credential_source(source: AgentCredentialSource) -> bool { + matches!( + source, + AgentCredentialSource::Env | AgentCredentialSource::Keyring | AgentCredentialSource::File + ) +} + +const fn should_delete_persistent_credentials(source: AgentCredentialSource) -> bool { + matches!( + source, + AgentCredentialSource::Keyring | AgentCredentialSource::File + ) +} + +async fn wait_for_callback( + listener: tokio::net::TcpListener, + expected_state: &str, + timeout: Duration, +) -> Result { + let (mut stream, _) = tokio::time::timeout(timeout, listener.accept()) + .await + .map_err(|_| "Timed out waiting for browser approval".to_string())? + .map_err(|error| format!("Failed to receive browser approval: {error}"))?; + let mut buffer = Vec::new(); + loop { + let mut chunk = [0_u8; 1_024]; + let length = stream + .read(&mut chunk) + .await + .map_err(|error| format!("Failed to read browser approval: {error}"))?; + if length == 0 { + return Err("Browser approval ended before the request was complete".to_string()); + } + buffer.extend_from_slice(&chunk[..length]); + if buffer.len() > 16 * 1024 { + return Err("Browser approval request was too large".to_string()); + } + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = std::str::from_utf8(&buffer) + .map_err(|_| "Browser approval was not valid HTTP".to_string())?; + let request_line = request + .lines() + .next() + .ok_or_else(|| "Browser approval was not valid HTTP".to_string())?; + let method = request_line + .split_whitespace() + .next() + .ok_or_else(|| "Browser approval was not valid HTTP".to_string())?; + if method != "GET" { + return Err("Browser approval used an invalid HTTP method".to_string()); + } + let target = request_line + .split_whitespace() + .nth(1) + .ok_or_else(|| "Browser approval was not valid HTTP".to_string())?; + let callback = Url::parse(&format!("http://127.0.0.1{target}")) + .map_err(|_| "Browser approval callback was invalid".to_string())?; + if callback.path() != "/callback" { + return Err("Browser approval callback had an invalid path".to_string()); + } + let state = callback + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .ok_or_else(|| "Browser approval callback did not include state".to_string())?; + if state != expected_state { + return Err("Browser approval state did not match".to_string()); + } + if let Some(error) = callback + .query_pairs() + .find_map(|(key, value)| (key == "error").then(|| value.into_owned())) + { + let body = "Authorization was cancelled.\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|write_error| write_error.to_string())?; + return Err(format!("Authorization was declined: {error}")); + } + let code = callback + .query_pairs() + .find_map(|(key, value)| (key == "code").then(|| value.into_owned())) + .ok_or_else(|| "Browser approval callback did not include a code".to_string())?; + let body = "Authorization complete. You can close this window.\n"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|error| error.to_string())?; + Ok(code) +} + +impl LoginArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + let format = resolve_format(global_json, self.format); + let result = self.run_inner(format).await; + if let Err(error) = &result + && format == OutputFormat::Json + { + let _ = write_json(&serde_json::json!({ "error": error })); + } + result + } + + async fn run_inner(self, format: OutputFormat) -> Result<(), String> { + if self.timeout == 0 || self.timeout > 900 { + return Err("--timeout must be between 1 and 900 seconds".to_string()); + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|error| format!("Failed to create the login callback: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| error.to_string())? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + let verifier = random_base64url(); + let state = random_base64url(); + let server = credentials::agent_server_url()?; + let mut authorize_url = Url::parse(&format!("{server}/cli/authorize")) + .map_err(|_| "CAP_SERVER_URL is not a valid URL".to_string())?; + authorize_url + .query_pairs_mut() + .append_pair("client_id", "cap-cli") + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("state", &state) + .append_pair("code_challenge", &code_challenge(&verifier)) + .append_pair("code_challenge_method", "S256") + .append_pair("scope", self.profile.scopes()); + + if self.no_open { + eprintln!("Open this URL to authorize Cap CLI:\n{authorize_url}"); + } else if let Err(error) = open::that(authorize_url.as_str()) { + eprintln!("Could not open a browser ({error}). Open this URL:\n{authorize_url}"); + } else { + eprintln!("Waiting for browser approval..."); + } + + let code = wait_for_callback(listener, &state, Duration::from_secs(self.timeout)).await?; + let client = auth_client()?; + let response = client + .post(format!("{server}/api/v1/auth/token")) + .json(&TokenRequest { + code: &code, + code_verifier: &verifier, + redirect_uri: &redirect_uri, + }) + .send() + .await + .map_err(|error| format!("Failed to exchange the authorization code: {error}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("Failed to read the token response: {error}"))?; + if !status.is_success() { + return Err(api_error(status, &body)); + } + let token: TokenResponse = + serde_json::from_str(&body).map_err(|_| "Cap returned an invalid token".to_string())?; + let storage = credentials::store_agent( + &StoredAgentCredential { + access_token: token.access_token.clone(), + expires_at: token.expires_at.clone(), + scopes: token.scopes.clone(), + server: server.clone(), + }, + self.allow_file_credential, + ); + let storage = match storage { + Ok(storage) => storage, + Err(error) => { + let _ = client + .post(format!("{server}/api/v1/auth/revoke")) + .bearer_auth(&token.access_token) + .send() + .await; + return Err(error); + } + }; + let result = LoginResult { + authenticated: true, + server, + expires_at: token.expires_at, + scopes: token.scopes, + storage, + }; + match format { + OutputFormat::Json => write_json(&result), + OutputFormat::Text => { + println!("Cap CLI is authorized."); + println!("server: {}", result.server); + println!("expires: {}", result.expires_at); + Ok(()) + } + } + } +} + +impl LogoutArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + let format = resolve_format(global_json, self.format); + let result = self.run_inner(format).await; + if let Err(error) = &result + && format == OutputFormat::Json + { + let _ = write_json(&serde_json::json!({ "error": error })); + } + result + } + + async fn run_inner(self, format: OutputFormat) -> Result<(), String> { + let credentials = credentials::resolve_agent()?; + if !is_agent_credential_source(credentials.source) { + return Err( + "No Cap CLI agent credential is stored. CAP_API_KEY and Cap Desktop login are not changed by `cap auth logout`." + .to_string(), + ); + } + let revocable = credentials.access_token.starts_with("cap_cli_"); + let revoked = if revocable { + let response = auth_client()? + .post(format!("{}/api/v1/auth/revoke", credentials.server)) + .bearer_auth(&credentials.access_token) + .send() + .await + .map_err(|error| format!("Failed to revoke the Cap credential: {error}"))?; + if response.status().is_success() { + response + .json::() + .await + .map_err(|_| "Cap returned an invalid revocation response".to_string())? + .revoked + } else if response.status() == reqwest::StatusCode::UNAUTHORIZED { + false + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(api_error(status, &body)); + } + } else { + false + }; + if should_delete_persistent_credentials(credentials.source) { + credentials::delete_agent()?; + } + let result = LogoutResult { + authenticated: false, + revoked, + }; + match format { + OutputFormat::Json => write_json(&result), + OutputFormat::Text => { + if credentials.source == AgentCredentialSource::Env { + println!("Cap CLI environment credential revoked."); + if std::io::stdin().is_terminal() { + println!("Unset CAP_AGENT_TOKEN to remove it from this shell."); + } + } else { + println!("Cap CLI credential removed."); + } + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_values_have_the_required_shape() { + let verifier = random_base64url(); + assert_eq!(verifier.len(), 43); + assert_eq!(code_challenge(&verifier).len(), 43); + } + + #[test] + fn login_profiles_are_incremental() { + let creator = LoginProfile::Creator.scopes(); + let admin = LoginProfile::Admin.scopes(); + let full = LoginProfile::Full.scopes(); + assert!(creator.contains("caps:upload")); + assert!(!creator.contains("organizations:manage")); + assert!(admin.contains("organizations:manage")); + assert!(!admin.contains("developer:secrets")); + assert!(full.contains("developer:secrets")); + } + + #[test] + fn errors_never_echo_unknown_response_bodies() { + let error = api_error(reqwest::StatusCode::BAD_GATEWAY, "secret upstream body"); + assert_eq!(error, "Cap returned HTTP 502 Bad Gateway"); + assert!(!error.contains("secret")); + } + + #[test] + fn logout_never_claims_legacy_credentials() { + assert!(is_agent_credential_source(AgentCredentialSource::Env)); + assert!(is_agent_credential_source(AgentCredentialSource::Keyring)); + assert!(is_agent_credential_source(AgentCredentialSource::File)); + assert!(!is_agent_credential_source( + AgentCredentialSource::LegacyEnv + )); + assert!(!is_agent_credential_source(AgentCredentialSource::Desktop)); + assert!(!should_delete_persistent_credentials( + AgentCredentialSource::Env + )); + assert!(should_delete_persistent_credentials( + AgentCredentialSource::Keyring + )); + assert!(should_delete_persistent_credentials( + AgentCredentialSource::File + )); + } + + #[tokio::test] + async fn callback_accepts_fragmented_loopback_requests() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let callback = tokio::spawn(wait_for_callback( + listener, + "expected_state", + Duration::from_secs(2), + )); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all(b"GET /callback?state=expected_state") + .await + .unwrap(); + tokio::task::yield_now().await; + client + .write_all(b"&code=one_time_code HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .await + .unwrap(); + assert_eq!(callback.await.unwrap().unwrap(), "one_time_code"); + } +} From 6bc3207761f3a0e4049ce2339f5d30916020e596 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 29/58] feat(cli): add agent HTTP client --- apps/cli/src/agent_client.rs | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 apps/cli/src/agent_client.rs diff --git a/apps/cli/src/agent_client.rs b/apps/cli/src/agent_client.rs new file mode 100644 index 00000000000..6ed1a4dbe77 --- /dev/null +++ b/apps/cli/src/agent_client.rs @@ -0,0 +1,124 @@ +use std::path::Path; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use clap::ValueEnum; +use reqwest::Method; +use serde_json::{Value, json}; + +use crate::{OutputFormat, caps::AgentClient, resolve_format, write_json}; + +pub fn print_value(value: &Value, format: OutputFormat) -> Result<(), String> { + match format { + OutputFormat::Json => write_json(value), + OutputFormat::Text => { + println!( + "{}", + serde_json::to_string_pretty(value).map_err(|error| error.to_string())? + ); + Ok(()) + } + } +} + +pub async fn read(path: &str, global_json: bool, format: OutputFormat) -> Result<(), String> { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .get_json(path) + .await + .map_err(|error| error.to_string())?; + print_value(&value, resolve_format(global_json, format)) +} + +pub async fn mutate( + method: Method, + path: &str, + body: &Value, + global_json: bool, + format: OutputFormat, +) -> Result<(), String> { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json(method, path, body) + .await + .map_err(|error| error.to_string())?; + print_value(&value, resolve_format(global_json, format)) +} + +pub async fn mutate_confirmed( + method: Method, + path: &str, + body: &Value, + global_json: bool, + format: OutputFormat, +) -> Result<(), String> { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed(method, path, body) + .await + .map_err(|error| error.to_string())?; + print_value(&value, resolve_format(global_json, format)) +} + +pub fn query(parameters: &[(&str, Option)]) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (key, value) in parameters { + if let Some(value) = value { + serializer.append_pair(key, value); + } + } + serializer.finish() +} + +pub fn image_payload(path: &Path) -> Result { + let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; + if metadata.len() == 0 || metadata.len() > 1024 * 1024 { + return Err("Image must be between 1 byte and 1 MB".to_string()); + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .ok_or_else(|| "Image must be a PNG or JPEG".to_string())?; + let content_type = match extension.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + _ => return Err("Image must be a PNG or JPEG".to_string()), + }; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "Image file name is invalid".to_string())?; + let data = std::fs::read(path).map_err(|error| error.to_string())?; + Ok(json!({ + "data": STANDARD.encode(data), + "contentType": content_type, + "fileName": file_name, + })) +} + +pub fn open_browser_action(value: &Value, no_open: bool) { + if no_open { + return; + } + let Some(url) = value.get("url").and_then(Value::as_str) else { + return; + }; + if let Err(error) = open::that(url) { + eprintln!("Could not open the browser: {error}"); + } +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum SpaceRole { + Admin, + Member, +} + +impl SpaceRole { + pub const fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Member => "member", + } + } +} From 8ec9d87575708839f09a369f54425066ed069216 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 30/58] feat(cli): extend credentials storage for agent tokens --- apps/cli/src/credentials.rs | 612 ++++++++++++++++++++++++++++++++++-- 1 file changed, 593 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/credentials.rs b/apps/cli/src/credentials.rs index c0005954a81..0b046d8a9e0 100644 --- a/apps/cli/src/credentials.rs +++ b/apps/cli/src/credentials.rs @@ -5,12 +5,17 @@ //! in there, `cap upload` just works — no key to copy, no env var to set. The desktop persists its //! auth as plain JSON via tauri-plugin-store, so we read it directly without any Tauri dependency. -use serde::Serialize; +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{OutputFormat, write_json}; +use crate::{OutputFormat, atomic, write_json}; -const DEFAULT_SERVER: &str = "https://cap.so"; +pub const DEFAULT_SERVER: &str = "https://cap.so"; +const AGENT_KEYRING_SERVICE: &str = "so.cap.cli"; +const AGENT_KEYRING_USER: &str = "agent-api"; +const AGENT_GRANTS_KEYRING_USER: &str = "agent-access-grants"; // Prod first, then the dev bundle, so a released install wins on a machine that has both. const DESKTOP_BUNDLE_IDS: [&str; 2] = ["so.cap.desktop", "so.cap.desktop.dev"]; @@ -21,8 +26,6 @@ pub enum CredentialSource { Env, /// The login stored by Cap Desktop. Desktop, - /// No credential found. - None, } pub struct Credentials { @@ -69,19 +72,347 @@ fn normalize_server(server: String) -> String { server.trim_end_matches('/').to_string() } +fn validate_agent_server(server: String) -> Result { + let server = normalize_server(server); + let url = url::Url::parse(&server).map_err(|_| "The Cap server URL is invalid".to_string())?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err( + "The Cap server URL cannot include credentials, a query, or a fragment".to_string(), + ); + } + let hostname = url + .host_str() + .ok_or_else(|| "The Cap server URL must include a host".to_string())?; + let is_loopback = matches!(hostname, "localhost" | "127.0.0.1" | "::1" | "[::1]"); + if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback) { + return Err( + "Cap CLI agent credentials require HTTPS. HTTP is allowed only for loopback development servers." + .to_string(), + ); + } + Ok(server) +} + fn env_var(name: &str) -> Option { std::env::var(name).ok().filter(|v| !v.is_empty()) } +pub fn server_url() -> String { + normalize_server( + env_var("CAP_SERVER_URL") + .or_else(|| load_desktop_store().as_ref().and_then(store_server)) + .unwrap_or_else(|| DEFAULT_SERVER.to_string()), + ) +} + +pub fn agent_server_url() -> Result { + validate_agent_server(server_url()) +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredAgentCredential { + pub access_token: String, + pub expires_at: String, + pub scopes: Vec, + pub server: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredAgentAccessGrant { + access_grant: String, + expires_at: String, +} + +#[derive(Default, Serialize, Deserialize)] +struct StoredAgentAccessGrants { + grants: BTreeMap, +} + +#[derive(Clone)] +pub struct AgentAccessGrant { + pub value: String, + pub expires_at: chrono::DateTime, +} + +#[derive(Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AgentCredentialSource { + Env, + Keyring, + File, + LegacyEnv, + Desktop, +} + +pub struct AgentCredentials { + pub access_token: String, + pub server: String, + pub source: AgentCredentialSource, +} + +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub enum AgentCredentialStorage { + Keyring, + PermissionRestrictedFile, +} + +const fn file_fallback_supported() -> bool { + cfg!(unix) +} + +fn agent_credential_path() -> Result { + dirs::config_dir() + .map(|path| path.join("cap").join("agent-credentials.json")) + .ok_or_else(|| "Could not locate the user configuration directory".to_string()) +} + +fn agent_grants_path() -> Result { + dirs::config_dir() + .map(|path| path.join("cap").join("agent-access-grants.json")) + .ok_or_else(|| "Could not locate the user configuration directory".to_string()) +} + +fn load_agent_keyring() -> Result { + let entry = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_KEYRING_USER) + .map_err(|error| error.to_string())?; + let value = entry.get_password().map_err(|error| error.to_string())?; + serde_json::from_str(&value).map_err(|error| error.to_string()) +} + +#[cfg(unix)] +fn load_agent_file() -> Result { + let bytes = std::fs::read(agent_credential_path()?).map_err(|error| error.to_string())?; + serde_json::from_slice(&bytes).map_err(|error| error.to_string()) +} + +#[cfg(not(unix))] +fn load_agent_file() -> Result { + Err("File credential fallback is unavailable on this platform".to_string()) +} + +#[cfg(unix)] +fn write_agent_file(credential: &StoredAgentCredential) -> Result<(), String> { + let path = agent_credential_path()?; + let parent = path + .parent() + .ok_or_else(|| "Agent credential path has no parent directory".to_string())?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + let bytes = serde_json::to_vec(credential).map_err(|error| error.to_string())?; + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string())?; + if let Err(error) = atomic::replace(&temporary, &path) { + let _ = std::fs::remove_file(&temporary); + return Err(error.to_string()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn write_agent_file(_credential: &StoredAgentCredential) -> Result<(), String> { + Err( + "File credential fallback is unavailable on this platform. Use the OS credential store or CAP_AGENT_TOKEN." + .to_string(), + ) +} + +#[cfg(unix)] +fn write_restricted_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Credential path has no parent directory".to_string())?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string())?; + if let Err(error) = atomic::replace(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(error.to_string()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn write_restricted_file(_path: &std::path::Path, _bytes: &[u8]) -> Result<(), String> { + Err( + "File credential fallback is unavailable on this platform. Use the OS credential store or CAP_AGENT_TOKEN." + .to_string(), + ) +} + +fn load_agent_access_grants() -> StoredAgentAccessGrants { + let keyring = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_GRANTS_KEYRING_USER) + .and_then(|entry| entry.get_password()) + .ok() + .and_then(|value| serde_json::from_str(&value).ok()); + if let Some(grants) = keyring { + return grants; + } + #[cfg(unix)] + { + std::fs::read(agent_grants_path().unwrap_or_default()) + .ok() + .and_then(|value| serde_json::from_slice(&value).ok()) + .unwrap_or_default() + } + #[cfg(not(unix))] + { + StoredAgentAccessGrants::default() + } +} + +pub fn agent_access_grant(video_id: &str) -> Option { + let grant = load_agent_access_grants().grants.remove(video_id)?; + let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .ok()? + .with_timezone(&chrono::Utc); + (expires_at > chrono::Utc::now()).then_some(AgentAccessGrant { + value: grant.access_grant, + expires_at, + }) +} + +pub fn store_agent_access_grant( + video_id: &str, + access_grant: String, + expires_at: String, + allow_file_fallback: bool, +) -> Result { + let mut grants = load_agent_access_grants(); + grants.grants.retain(|_, grant| { + chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .is_ok_and(|expires_at| expires_at > chrono::Utc::now()) + }); + grants.grants.insert( + video_id.to_string(), + StoredAgentAccessGrant { + access_grant, + expires_at, + }, + ); + let serialized = serde_json::to_string(&grants).map_err(|error| error.to_string())?; + let keyring_result = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_GRANTS_KEYRING_USER) + .and_then(|entry| entry.set_password(&serialized)); + if keyring_result.is_ok() { + let _ = std::fs::remove_file(agent_grants_path()?); + return Ok(AgentCredentialStorage::Keyring); + } + if !allow_file_fallback { + return Err( + "The OS credential store is unavailable. Re-run with --allow-file-credential to acknowledge storing the access grant in a permission-restricted file." + .to_string(), + ); + } + if !file_fallback_supported() { + return Err( + "File credential fallback is unavailable on this platform. Use the OS credential store or CAP_AGENT_TOKEN." + .to_string(), + ); + } + write_restricted_file(&agent_grants_path()?, serialized.as_bytes())?; + Ok(AgentCredentialStorage::PermissionRestrictedFile) +} + +pub fn store_agent( + credential: &StoredAgentCredential, + allow_file_fallback: bool, +) -> Result { + validate_agent_server(credential.server.clone())?; + let serialized = serde_json::to_string(credential).map_err(|error| error.to_string())?; + let keyring_result = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_KEYRING_USER) + .and_then(|entry| entry.set_password(&serialized)); + if keyring_result.is_ok() { + let _ = std::fs::remove_file(agent_credential_path()?); + return Ok(AgentCredentialStorage::Keyring); + } + if !allow_file_fallback { + return Err( + "The OS credential store is unavailable. Re-run with --allow-file-credential to acknowledge storing the token in a permission-restricted file." + .to_string(), + ); + } + if !file_fallback_supported() { + return Err( + "File credential fallback is unavailable on this platform. Use the OS credential store or CAP_AGENT_TOKEN." + .to_string(), + ); + } + write_agent_file(credential)?; + Ok(AgentCredentialStorage::PermissionRestrictedFile) +} + +pub fn resolve_agent() -> Result { + let server = server_url(); + if let Some(access_token) = env_var("CAP_AGENT_TOKEN") { + return Ok(AgentCredentials { + access_token, + server: validate_agent_server(server)?, + source: AgentCredentialSource::Env, + }); + } + if let Ok(stored) = load_agent_keyring() { + return Ok(AgentCredentials { + access_token: stored.access_token, + server: validate_agent_server(stored.server)?, + source: AgentCredentialSource::Keyring, + }); + } + if let Ok(stored) = load_agent_file() { + return Ok(AgentCredentials { + access_token: stored.access_token, + server: validate_agent_server(stored.server)?, + source: AgentCredentialSource::File, + }); + } + let legacy = resolve()?; + let source = match legacy.source { + CredentialSource::Env => AgentCredentialSource::LegacyEnv, + CredentialSource::Desktop => AgentCredentialSource::Desktop, + }; + Ok(AgentCredentials { + access_token: legacy.api_key, + server: legacy.server, + source, + }) +} + +pub fn delete_agent() -> Result<(), String> { + if let Ok(entry) = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_KEYRING_USER) { + let _ = entry.delete_credential(); + } + if let Ok(entry) = keyring::Entry::new(AGENT_KEYRING_SERVICE, AGENT_GRANTS_KEYRING_USER) { + let _ = entry.delete_credential(); + } + for path in [agent_credential_path()?, agent_grants_path()?] { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + Ok(()) +} + /// Resolve the upload credential and target server. Returns a clear, actionable error when neither an /// env var nor a desktop login is available. pub fn resolve() -> Result { let store = load_desktop_store(); - let server = normalize_server( - env_var("CAP_SERVER_URL") - .or_else(|| store.as_ref().and_then(store_server)) - .unwrap_or_else(|| DEFAULT_SERVER.to_string()), - ); + let server = server_url(); if let Some(api_key) = env_var("CAP_API_KEY") { return Ok(Credentials { @@ -119,23 +450,218 @@ pub fn resolve() -> Result { #[serde(rename_all = "camelCase")] struct AuthStatus { authenticated: bool, - source: CredentialSource, + credential_present: bool, + server_verified: bool, + verification_status: AuthVerificationStatus, + source: AuthStatusSource, server: String, #[serde(skip_serializing_if = "Option::is_none")] user_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scopes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option, +} + +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum AuthVerificationStatus { + Verified, + Rejected, + Unavailable, + LocalOnly, + Missing, +} + +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +enum AuthStatusSource { + Env, + Keyring, + File, + Desktop, + None, +} + +enum StatusCredentials { + Agent(AgentCredentials), + Legacy(Credentials), +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentServerStatus { + authenticated: bool, + expires_at: Option, + scopes: Vec, +} + +struct AgentVerification { + authenticated: bool, + server_verified: bool, + status: AuthVerificationStatus, + expires_at: Option, + scopes: Option>, hint: Option, } +async fn verify_agent_status(credentials: &AgentCredentials) -> AgentVerification { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(client) => client, + Err(_) => { + return AgentVerification { + authenticated: false, + server_verified: false, + status: AuthVerificationStatus::Unavailable, + expires_at: None, + scopes: None, + hint: Some("Could not initialize secure authentication verification".to_string()), + }; + } + }; + let response = match client + .get(format!("{}/api/v1/auth/status", credentials.server)) + .bearer_auth(&credentials.access_token) + .send() + .await + { + Ok(response) => response, + Err(_) => { + return AgentVerification { + authenticated: false, + server_verified: false, + status: AuthVerificationStatus::Unavailable, + expires_at: None, + scopes: None, + hint: Some( + "The credential is present, but the Cap server could not be reached" + .to_string(), + ), + }; + } + }; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return AgentVerification { + authenticated: false, + server_verified: true, + status: AuthVerificationStatus::Rejected, + expires_at: None, + scopes: None, + hint: Some( + "The Cap server rejected this credential. Run `cap auth login` again".to_string(), + ), + }; + } + if !response.status().is_success() { + return AgentVerification { + authenticated: false, + server_verified: false, + status: AuthVerificationStatus::Unavailable, + expires_at: None, + scopes: None, + hint: Some(format!( + "The credential is present, but Cap could not verify it (HTTP {})", + response.status() + )), + }; + } + let status = match response.json::().await { + Ok(status) => status, + Err(_) => { + return AgentVerification { + authenticated: false, + server_verified: false, + status: AuthVerificationStatus::Unavailable, + expires_at: None, + scopes: None, + hint: Some("Cap returned an invalid authentication status".to_string()), + }; + } + }; + AgentVerification { + authenticated: status.authenticated, + server_verified: true, + status: if status.authenticated { + AuthVerificationStatus::Verified + } else { + AuthVerificationStatus::Rejected + }, + expires_at: status.expires_at, + scopes: Some(status.scopes), + hint: (!status.authenticated).then(|| { + "The Cap server rejected this credential. Run `cap auth login` again".to_string() + }), + } +} + +fn resolve_status() -> Result { + let agent = resolve_agent(); + if env_var("CAP_AGENT_TOKEN").is_some() { + return agent.map(StatusCredentials::Agent); + } + let legacy = resolve(); + if matches!( + legacy.as_ref().map(|credentials| credentials.source), + Ok(CredentialSource::Env) + ) { + return legacy.map(StatusCredentials::Legacy); + } + match agent { + Ok(credentials) + if matches!( + credentials.source, + AgentCredentialSource::Keyring | AgentCredentialSource::File + ) => + { + Ok(StatusCredentials::Agent(credentials)) + } + _ => legacy.map(StatusCredentials::Legacy), + } +} + /// `cap auth status` — report whether a credential is available and where it came from, without ever /// printing the secret. Lets an agent check before attempting an upload. -pub fn status(format: OutputFormat) -> Result<(), String> { - let status = match resolve() { - Ok(creds) => AuthStatus { +pub async fn status(format: OutputFormat) -> Result<(), String> { + let status = match resolve_status() { + Ok(StatusCredentials::Agent(creds)) => { + let verification = verify_agent_status(&creds).await; + AuthStatus { + authenticated: verification.authenticated, + credential_present: true, + server_verified: verification.server_verified, + verification_status: verification.status, + source: match creds.source { + AgentCredentialSource::Env => AuthStatusSource::Env, + AgentCredentialSource::Keyring => AuthStatusSource::Keyring, + AgentCredentialSource::File => AuthStatusSource::File, + AgentCredentialSource::LegacyEnv => AuthStatusSource::Env, + AgentCredentialSource::Desktop => AuthStatusSource::Desktop, + }, + server: creds.server, + user_id: None, + expires_at: verification.expires_at, + scopes: verification.scopes, + hint: verification.hint, + } + } + Ok(StatusCredentials::Legacy(creds)) => AuthStatus { authenticated: true, - source: creds.source, + credential_present: true, + server_verified: false, + verification_status: AuthVerificationStatus::LocalOnly, + source: match creds.source { + CredentialSource::Env => AuthStatusSource::Env, + CredentialSource::Desktop => AuthStatusSource::Desktop, + }, server: creds.server, user_id: creds.user_id, + expires_at: None, + scopes: None, hint: None, }, Err(hint) => { @@ -146,9 +672,14 @@ pub fn status(format: OutputFormat) -> Result<(), String> { ); AuthStatus { authenticated: false, - source: CredentialSource::None, + credential_present: false, + server_verified: false, + verification_status: AuthVerificationStatus::Missing, + source: AuthStatusSource::None, server, user_id: None, + expires_at: None, + scopes: None, hint: Some(hint), } } @@ -159,12 +690,25 @@ pub fn status(format: OutputFormat) -> Result<(), String> { OutputFormat::Text => { if status.authenticated { let source = match status.source { - CredentialSource::Env => "CAP_API_KEY env var", - CredentialSource::Desktop => "Cap Desktop login", - CredentialSource::None => "none", + AuthStatusSource::Env => "environment credential", + AuthStatusSource::Keyring => "OS credential store", + AuthStatusSource::File => "permission-restricted file", + AuthStatusSource::Desktop => "Cap Desktop login", + AuthStatusSource::None => "none", }; println!("authenticated: yes (via {source})"); println!("server: {}", status.server); + } else if status.credential_present { + println!("credential: present"); + if status.server_verified { + println!("authenticated: no (server rejected credential)"); + } else { + println!("authenticated: unknown (server verification unavailable)"); + } + println!("server: {}", status.server); + if let Some(hint) = &status.hint { + println!("{hint}"); + } } else { println!("authenticated: no"); println!("server: {}", status.server); @@ -176,3 +720,33 @@ pub fn status(format: OutputFormat) -> Result<(), String> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_servers_require_https_except_for_loopback_development() { + assert_eq!( + validate_agent_server("https://cap.so/".to_string()).unwrap(), + "https://cap.so" + ); + assert!(validate_agent_server("http://127.0.0.1:3000".to_string()).is_ok()); + assert!(validate_agent_server("http://[::1]:3000".to_string()).is_ok()); + assert!(validate_agent_server("http://localhost:3000".to_string()).is_ok()); + for server in [ + "http://cap.so", + "ftp://cap.so", + "https://user:secret@cap.so", + "https://cap.so?token=secret", + "https://cap.so#secret", + ] { + assert!(validate_agent_server(server.to_string()).is_err()); + } + } + + #[test] + fn file_fallback_is_only_available_where_permissions_are_enforced() { + assert_eq!(file_fallback_supported(), cfg!(unix)); + } +} From 3c30fd4ae19cf44cec7063c8b71142a7bd8a9fcb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 31/58] feat(cli): add account commands --- apps/cli/src/account.rs | 180 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 apps/cli/src/account.rs diff --git a/apps/cli/src/account.rs b/apps/cli/src/account.rs new file mode 100644 index 00000000000..ffb5ca0510b --- /dev/null +++ b/apps/cli/src/account.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; + +use clap::{ArgGroup, Args, Subcommand}; +use reqwest::Method; +use serde_json::{Map, Value, json}; + +use crate::{ + OutputFormat, agent_client, caps::AgentClient, confirmation, credentials, resolve_format, +}; + +#[derive(Args)] +pub struct AccountArgs { + #[command(subcommand)] + command: AccountCommands, +} + +#[derive(Subcommand)] +enum AccountCommands { + Get(FormatArgs), + Update(AccountUpdateArgs), + Image(AccountImageArgs), + Referrals(AccountReferralsArgs), + SignOutAll(AccountSignOutAllArgs), +} + +#[derive(Args)] +struct FormatArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +#[command(group(ArgGroup::new("name_action").args(["name", "clear_name"])))] +#[command(group(ArgGroup::new("last_name_action").args(["last_name", "clear_last_name"])))] +#[command(group(ArgGroup::new("organization_action").args(["default_organization", "clear_default_organization"])))] +struct AccountUpdateArgs { + #[arg(long)] + name: Option, + #[arg(long)] + clear_name: bool, + #[arg(long)] + last_name: Option, + #[arg(long)] + clear_last_name: bool, + #[arg(long)] + default_organization: Option, + #[arg(long)] + clear_default_organization: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct AccountImageArgs { + #[command(subcommand)] + command: AccountImageCommands, +} + +#[derive(Subcommand)] +enum AccountImageCommands { + Set(AccountImageSetArgs), + Remove(AccountImageRemoveArgs), +} + +#[derive(Args)] +struct AccountImageSetArgs { + image: PathBuf, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct AccountImageRemoveArgs { + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct AccountSignOutAllArgs { + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct AccountReferralsArgs { + #[arg(long)] + no_open: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +impl AccountArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + match self.command { + AccountCommands::Get(args) => agent_client::read("/me", global_json, args.format).await, + AccountCommands::Update(args) => { + let mut body = Map::new(); + if let Some(name) = args.name { + body.insert("name".to_string(), Value::String(name)); + } else if args.clear_name { + body.insert("name".to_string(), Value::Null); + } + if let Some(last_name) = args.last_name { + body.insert("lastName".to_string(), Value::String(last_name)); + } else if args.clear_last_name { + body.insert("lastName".to_string(), Value::Null); + } + if let Some(organization_id) = args.default_organization { + body.insert( + "defaultOrganizationId".to_string(), + Value::String(organization_id), + ); + } else if args.clear_default_organization { + body.insert("defaultOrganizationId".to_string(), Value::Null); + } + if body.is_empty() { + return Err("Provide at least one account update".to_string()); + } + confirmation::require(args.yes, "Update the Cap account")?; + agent_client::mutate(Method::PATCH, "/me", &json!(body), global_json, args.format) + .await + } + AccountCommands::Image(args) => match args.command { + AccountImageCommands::Set(args) => { + confirmation::require(args.yes, "Update the Cap profile image")?; + let body = agent_client::image_payload(&args.image)?; + agent_client::mutate_confirmed( + Method::PUT, + "/me/image", + &body, + global_json, + args.format, + ) + .await + } + AccountImageCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the Cap profile image")?; + agent_client::mutate_confirmed( + Method::DELETE, + "/me/image", + &json!({}), + global_json, + args.format, + ) + .await + } + }, + AccountCommands::Referrals(args) => { + confirmation::require(args.yes, "Open the Cap referral portal")?; + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed(Method::POST, "/me/referrals", &json!({})) + .await + .map_err(|error| error.to_string())?; + agent_client::open_browser_action(&value, args.no_open); + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + AccountCommands::SignOutAll(args) => { + confirmation::require(args.yes, "Sign out every Cap device and agent")?; + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed(Method::POST, "/me/sign-out-all", &json!({})) + .await + .map_err(|error| error.to_string())?; + credentials::delete_agent()?; + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + } + } +} From 21c70c9876da448a6dbe9c7c14a44ade042f4461 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 32/58] feat(cli): add caps commands --- apps/cli/src/caps.rs | 1944 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1944 insertions(+) create mode 100644 apps/cli/src/caps.rs diff --git a/apps/cli/src/caps.rs b/apps/cli/src/caps.rs new file mode 100644 index 00000000000..088a14f7122 --- /dev/null +++ b/apps/cli/src/caps.rs @@ -0,0 +1,1944 @@ +use std::{ + collections::HashMap, + io::{IsTerminal, Read}, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; + +use clap::{ArgGroup, Args, Subcommand, ValueEnum}; +use futures::StreamExt; +use reqwest::{Method, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use tokio::io::AsyncWriteExt; +use url::Url; + +use crate::{OutputFormat, atomic, confirmation, credentials, resolve_format, write_json}; + +#[derive(Args)] +pub struct CapsArgs { + #[command(subcommand)] + command: CapsCommands, +} + +#[derive(Subcommand)] +enum CapsCommands { + List(ListArgs), + Get(TargetArgs), + Context(TargetArgs), + Status(TargetArgs), + Wait(WaitArgs), + Process(ProcessArgs), + Import(ImportArgs), + Transcript(TranscriptArgs), + TranscriptReplace(TranscriptReplaceArgs), + Download(DownloadArgs), + Duplicate(CapOperationArgs), + Delete(CapOperationArgs), + Password(PasswordArgs), + Unlock(UnlockArgs), + Comments(CommentsArgs), + Reactions(ReactionsArgs), + Update(UpdateArgs), + Sharing(SharingArgs), + Settings(SettingsArgs), + Date(DateArgs), + Move(MoveArgs), + Shares(SharesArgs), +} + +#[derive(Clone, Copy, ValueEnum)] +enum CapsScope { + All, + Owned, + Shared, +} + +impl CapsScope { + const fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Owned => "owned", + Self::Shared => "shared", + } + } +} + +#[derive(Args)] +struct ListArgs { + #[arg(long, value_enum, default_value_t = CapsScope::All)] + scope: CapsScope, + #[arg(long)] + organization: Option, + #[arg(long)] + folder: Option, + #[arg(long)] + search: Option, + #[arg(long)] + updated_after: Option, + #[arg(long)] + cursor: Option, + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u16).range(1..=100))] + limit: u16, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct TargetArgs { + cap: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum WaitFor { + Transcript, + Ai, + All, +} + +#[derive(Clone, Copy, ValueEnum)] +enum ProcessTarget { + Transcript, + Ai, + All, +} + +impl ProcessTarget { + const fn as_str(self) -> &'static str { + match self { + Self::Transcript => "transcript", + Self::Ai => "ai", + Self::All => "all", + } + } +} + +#[derive(Args)] +struct ProcessArgs { + cap: String, + #[arg(long, value_enum, default_value_t = ProcessTarget::All)] + target: ProcessTarget, + #[arg(long)] + retry: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct ImportArgs { + #[command(subcommand)] + command: ImportCommands, +} + +#[derive(Subcommand)] +enum ImportCommands { + Loom(LoomImportArgs), +} + +#[derive(Args)] +struct LoomImportArgs { + loom_url: String, + #[arg(long)] + organization: String, + #[arg(long)] + owner_email: Option, + #[arg(long)] + space: Option, + #[arg(long)] + wait: bool, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct WaitArgs { + cap: String, + #[arg(long, value_enum, default_value_t = WaitFor::All)] + r#for: WaitFor, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum TranscriptFormat { + Text, + Json, + Vtt, +} + +impl TranscriptFormat { + const fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + Self::Vtt => "vtt", + } + } +} + +#[derive(Args)] +struct TranscriptArgs { + cap: String, + #[arg(long, value_enum, default_value_t = TranscriptFormat::Text)] + format: TranscriptFormat, + #[arg(long)] + output: PathBuf, +} + +#[derive(Args)] +struct TranscriptReplaceArgs { + cap: String, + #[arg(long)] + input: PathBuf, + #[arg(long)] + expected_revision: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DownloadArgs { + cap: String, + #[arg(long)] + output: PathBuf, +} + +#[derive(Args)] +struct CapOperationArgs { + cap: String, + #[arg(long)] + wait: bool, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct PasswordArgs { + #[command(subcommand)] + command: PasswordCommands, +} + +#[derive(Subcommand)] +enum PasswordCommands { + Set(PasswordSetArgs), + Clear(PasswordClearArgs), +} + +#[derive(Args)] +struct PasswordSetArgs { + cap: String, + #[arg(long)] + password_stdin: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct PasswordClearArgs { + cap: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct UnlockArgs { + cap: String, + #[arg(long)] + password_stdin: bool, + #[arg( + long, + help = "Allow a permission-restricted file fallback on macOS or Linux when the OS credential store is unavailable" + )] + allow_file_credential: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct CommentsArgs { + #[command(subcommand)] + command: CommentsCommands, +} + +#[derive(Subcommand)] +enum CommentsCommands { + Add(CommentAddArgs), + Reply(CommentReplyArgs), + Delete(CommentDeleteArgs), +} + +#[derive(Args)] +struct CommentAddArgs { + cap: String, + content: String, + #[arg(long)] + timestamp_ms: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct CommentReplyArgs { + cap: String, + comment_id: String, + content: String, + #[arg(long)] + timestamp_ms: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct CommentDeleteArgs { + cap: String, + comment_id: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct ReactionsArgs { + #[command(subcommand)] + command: ReactionsCommands, +} + +#[derive(Subcommand)] +enum ReactionsCommands { + Add(ReactionAddArgs), +} + +#[derive(Args)] +struct ReactionAddArgs { + cap: String, + reaction: String, + #[arg(long)] + timestamp_ms: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct UpdateArgs { + cap: String, + #[arg(long)] + title: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SharingArgs { + #[command(subcommand)] + command: SharingCommands, +} + +#[derive(Subcommand)] +enum SharingCommands { + Set(SharingSetArgs), +} + +#[derive(Args)] +#[command(group(ArgGroup::new("visibility").required(true).args(["public", "private"])))] +struct SharingSetArgs { + cap: String, + #[arg(long)] + public: bool, + #[arg(long)] + private: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SettingsArgs { + #[command(subcommand)] + command: SettingsCommands, +} + +#[derive(Subcommand)] +enum SettingsCommands { + Get(TargetArgs), + Set(SettingsSetArgs), +} + +#[derive(Args)] +struct SettingsSetArgs { + cap: String, + #[arg(long)] + disable_summary: Option, + #[arg(long)] + disable_captions: Option, + #[arg(long)] + disable_chapters: Option, + #[arg(long)] + disable_reactions: Option, + #[arg(long)] + disable_transcript: Option, + #[arg(long)] + disable_comments: Option, + #[arg(long)] + default_playback_speed: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DateArgs { + #[command(subcommand)] + command: DateCommands, +} + +#[derive(Subcommand)] +enum DateCommands { + Set(DateSetArgs), +} + +#[derive(Args)] +struct DateSetArgs { + cap: String, + created_at: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum CapContainer { + Personal, + Organization, + Space, +} + +impl CapContainer { + const fn as_str(self) -> &'static str { + match self { + Self::Personal => "personal", + Self::Organization => "organization", + Self::Space => "space", + } + } +} + +#[derive(Args)] +struct MoveArgs { + cap: String, + #[arg(long, value_enum)] + container: CapContainer, + #[arg(long)] + organization: String, + #[arg(long)] + space: Option, + #[arg(long)] + folder: Option, + #[arg(long)] + root: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SharesArgs { + #[command(subcommand)] + command: ShareCommands, +} + +#[derive(Subcommand)] +enum ShareCommands { + List(TargetArgs), + Organization(OrganizationSharesArgs), + Space(SpaceSharesArgs), +} + +#[derive(Args)] +struct OrganizationSharesArgs { + #[command(subcommand)] + command: OrganizationShareCommands, +} + +#[derive(Subcommand)] +enum OrganizationShareCommands { + Add(OrganizationShareArgs), + Remove(OrganizationShareRemoveArgs), +} + +#[derive(Args)] +struct OrganizationShareArgs { + cap: String, + organization: String, + #[arg(long)] + folder: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationShareRemoveArgs { + cap: String, + organization: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceSharesArgs { + #[command(subcommand)] + command: SpaceShareCommands, +} + +#[derive(Subcommand)] +enum SpaceShareCommands { + Add(SpaceShareArgs), + Remove(SpaceShareRemoveArgs), +} + +#[derive(Args)] +struct SpaceShareArgs { + cap: String, + space: String, + #[arg(long)] + folder: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceShareRemoveArgs { + cap: String, + space: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentApiError { + pub code: String, + pub message: String, + pub retryable: bool, + pub retry_after_ms: Option, + pub request_id: Option, +} + +impl std::fmt::Display for AgentApiError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for AgentApiError {} + +impl AgentApiError { + fn local(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + retryable: false, + retry_after_ms: None, + request_id: None, + } + } +} + +#[derive(Clone)] +pub struct AgentClient { + http: reqwest::Client, + access_token: String, + server: String, + access_grants: Arc>>, + access_grant_checks: Arc>>, + reload_access_grants: bool, +} + +#[derive(Clone, Copy)] +enum AgentRequestBody<'a> { + Json(&'a Value), + Text(&'a str), +} + +impl AgentClient { + pub fn from_credentials() -> Result { + let credential = credentials::resolve_agent() + .map_err(|message| AgentApiError::local("AUTH_REQUIRED", message))?; + let mut client = Self::new(credential.server, credential.access_token)?; + client.reload_access_grants = true; + Ok(client) + } + + pub fn new(server: String, access_token: String) -> Result { + let parsed = Url::parse(&server) + .map_err(|_| AgentApiError::local("INVALID_REQUEST", "Invalid Cap server URL"))?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Cap server URL must use HTTP or HTTPS", + )); + } + let http = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(60)) + .user_agent(concat!("cap-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()))?; + Ok(Self { + http, + access_token, + server: server.trim_end_matches('/').to_string(), + access_grants: Arc::new(RwLock::new(HashMap::new())), + access_grant_checks: Arc::new(RwLock::new(HashMap::new())), + reload_access_grants: false, + }) + } + + fn endpoint(&self, path: &str) -> String { + format!("{}/api/v1{path}", self.server) + } + + async fn parse_error(response: Response) -> AgentApiError { + let status = response.status(); + let body = response.bytes().await.unwrap_or_default(); + serde_json::from_slice::(&body).unwrap_or_else(|_| { + AgentApiError::local( + if status == StatusCode::UNAUTHORIZED { + "AUTH_REQUIRED" + } else if status == StatusCode::TOO_MANY_REQUESTS { + "RATE_LIMITED" + } else { + "TEMPORARY_UNAVAILABLE" + }, + format!("Cap returned HTTP {status}"), + ) + }) + } + + async fn request( + &self, + method: Method, + path: &str, + body: Option>, + idempotency_key: Option<&str>, + user_confirmed: bool, + ) -> Result { + let mut attempt = 0_u32; + loop { + let mut request = self + .http + .request(method.clone(), self.endpoint(path)) + .bearer_auth(&self.access_token); + match body { + Some(AgentRequestBody::Json(body)) => request = request.json(body), + Some(AgentRequestBody::Text(body)) => { + request = request + .header("Content-Type", "text/plain; charset=utf-8") + .body(body.to_string()); + } + None => {} + } + if let Some(grant) = self.access_grant_for_path(path) { + request = request.header("X-Cap-Access-Grant", grant); + } + if let Some(key) = idempotency_key { + request = request.header("Idempotency-Key", key); + } + if user_confirmed { + request = request.header("X-Cap-Confirmation", "user"); + } + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + let error = AgentApiError { + code: "TEMPORARY_UNAVAILABLE".to_string(), + message: error.to_string(), + retryable: true, + retry_after_ms: Some(500), + request_id: None, + }; + if attempt >= 2 { + return Err(error); + } + tokio::time::sleep(Duration::from_millis(error.retry_after_ms.unwrap_or(500))) + .await; + attempt += 1; + continue; + } + }; + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let error = Self::parse_error(response).await; + let retryable = error.retryable + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error(); + if !retryable || attempt >= 2 { + return Err(error); + } + let delay = error + .retry_after_ms + .unwrap_or(200_u64.saturating_mul(1_u64 << attempt)) + .min(5_000); + tokio::time::sleep(Duration::from_millis(delay)).await; + attempt += 1; + } + } + + pub async fn get_json(&self, path: &str) -> Result { + self.request(Method::GET, path, None, None, false) + .await? + .json() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + + pub async fn mutate_json( + &self, + method: Method, + path: &str, + body: &Value, + ) -> Result { + let idempotency_key = uuid::Uuid::new_v4().to_string(); + self.request( + method, + path, + Some(AgentRequestBody::Json(body)), + Some(&idempotency_key), + false, + ) + .await? + .json() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + + pub async fn mutate_json_confirmed( + &self, + method: Method, + path: &str, + body: &Value, + ) -> Result { + let idempotency_key = uuid::Uuid::new_v4().to_string(); + self.request( + method, + path, + Some(AgentRequestBody::Json(body)), + Some(&idempotency_key), + true, + ) + .await? + .json() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + + async fn mutate_text_confirmed( + &self, + method: Method, + path: &str, + body: &str, + ) -> Result { + let idempotency_key = uuid::Uuid::new_v4().to_string(); + self.request( + method, + path, + Some(AgentRequestBody::Text(body)), + Some(&idempotency_key), + true, + ) + .await? + .json() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + + async fn stream_authorized(&self, path: &str, output: &Path) -> Result { + let response = self.request(Method::GET, path, None, None, false).await?; + stream_response(response, output).await + } + + async fn unlock_json(&self, id: &str, password: &str) -> Result { + self.request( + Method::POST, + &format!("/caps/{id}/unlock"), + Some(AgentRequestBody::Text(password)), + None, + false, + ) + .await? + .json() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + + fn access_grant_for_path(&self, path: &str) -> Option { + let id = path + .strip_prefix("/caps/")? + .split(['/', '?']) + .next() + .filter(|id| !id.is_empty())?; + if let Some(grant) = self + .access_grants + .read() + .ok() + .and_then(|grants| grants.get(id).cloned()) + .filter(|grant| grant.expires_at > chrono::Utc::now()) + { + return Some(grant.value); + } + if !self.reload_access_grants { + return None; + } + if self + .access_grant_checks + .read() + .ok() + .and_then(|checks| checks.get(id).copied()) + .is_some_and(|checked_at| checked_at.elapsed() < Duration::from_secs(1)) + { + return None; + } + if let Ok(mut checks) = self.access_grant_checks.write() { + checks.insert(id.to_string(), Instant::now()); + } + let grant = credentials::agent_access_grant(id)?; + if let Ok(mut grants) = self.access_grants.write() { + grants.insert(id.to_string(), grant.clone()); + } + Some(grant.value) + } +} + +pub fn cap_id(value: &str) -> Result { + let id = if value.starts_with("http://") || value.starts_with("https://") { + let url = Url::parse(value) + .map_err(|_| AgentApiError::local("INVALID_REQUEST", "Invalid Cap URL"))?; + let segments = url + .path_segments() + .ok_or_else(|| AgentApiError::local("INVALID_REQUEST", "Invalid Cap URL"))? + .filter(|segment| !segment.is_empty()) + .collect::>(); + match segments.as_slice() { + [kind, id] if matches!(*kind, "s" | "watch" | "embed" | "c") => (*id).to_string(), + [id] => (*id).to_string(), + _ => { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "URL does not identify a Cap", + )); + } + } + } else { + value.to_string() + }; + if id.len() < 5 + || id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Cap ID contains invalid characters", + )); + } + Ok(id) +} + +fn encode_query(parameters: &[(&str, Option)]) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (key, value) in parameters { + if let Some(value) = value { + serializer.append_pair(key, value); + } + } + serializer.finish() +} + +async fn stream_response(response: Response, output: &Path) -> Result { + if let Some(parent) = output.parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()))?; + } + let temporary = output.with_extension(format!("part-{}", uuid::Uuid::new_v4())); + let mut file = tokio::fs::File::create(&temporary) + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()))?; + let mut stream = response.bytes_stream(); + let mut written = 0_u64; + let write_result = async { + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| { + AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()) + })?; + file.write_all(&chunk).await.map_err(|error| { + AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()) + })?; + written = written.saturating_add(chunk.len() as u64); + } + file.flush() + .await + .map_err(|error| AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string())) + } + .await; + drop(file); + if let Err(error) = write_result { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(error); + } + if let Err(error) = atomic::replace(&temporary, output) { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + error.to_string(), + )); + } + Ok(written) +} + +fn print_value(value: &Value, format: OutputFormat) -> Result<(), String> { + match format { + OutputFormat::Json => write_json(value), + OutputFormat::Text => { + println!( + "{}", + serde_json::to_string_pretty(value).map_err(|error| error.to_string())? + ); + Ok(()) + } + } +} + +fn wait_complete(status: &Value, wait_for: WaitFor) -> Result { + let state = |key: &str| { + status + .get(key) + .and_then(|value| value.get("status")) + .and_then(Value::as_str) + }; + let complete = |key| match state(key) { + Some("complete" | "skipped" | "no_audio" | "unavailable") => Ok(true), + Some("error") => Err(AgentApiError::local( + "NOT_READY", + format!("{key} processing failed"), + )), + Some(_) => Ok(false), + None => Err(AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid status", + )), + }; + match wait_for { + WaitFor::Transcript => complete("transcript"), + WaitFor::Ai => complete("ai"), + WaitFor::All => Ok(complete("transcript")? && complete("ai")?), + } +} + +pub fn opaque_id(value: &str, field: &str) -> Result { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err(AgentApiError::local( + "INVALID_REQUEST", + format!("{field} contains invalid characters"), + )); + } + Ok(value.to_string()) +} + +fn read_unlock_password(password_stdin: bool) -> Result { + let password = if password_stdin { + let mut value = String::new(); + std::io::stdin() + .lock() + .take(1_025) + .read_to_string(&mut value) + .map_err(|error| AgentApiError::local("INVALID_REQUEST", error.to_string()))?; + if let Some(value) = value.strip_suffix('\n') { + value.strip_suffix('\r').unwrap_or(value).to_string() + } else { + value + } + } else { + if !std::io::stdin().is_terminal() { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Non-interactive unlock requires --password-stdin", + )); + } + rpassword::prompt_password("Cap password: ") + .map_err(|error| AgentApiError::local("INVALID_REQUEST", error.to_string()))? + }; + if password.is_empty() || password.len() > 512 { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Password must be between 1 and 512 bytes", + )); + } + Ok(password) +} + +async fn run_cap_operation( + client: &AgentClient, + args: CapOperationArgs, + deleting: bool, + global_json: bool, +) -> Result<(), AgentApiError> { + let id = cap_id(&args.cap)?; + confirmation::require( + args.yes, + if deleting { + "Permanently delete the Cap and its media" + } else { + "Duplicate the Cap and its media" + }, + ) + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let path = if deleting { + format!("/caps/{id}") + } else { + format!("/caps/{id}/duplicate") + }; + let value = client + .mutate_json_confirmed( + if deleting { + Method::DELETE + } else { + Method::POST + }, + &path, + &json!({}), + ) + .await?; + let value = if args.wait { + let operation_id = value.get("id").and_then(Value::as_str).ok_or_else(|| { + AgentApiError::local("TEMPORARY_UNAVAILABLE", "Cap returned an invalid operation") + })?; + crate::jobs::wait_operation(client, operation_id, args.timeout).await? + } else { + value + }; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) +} + +impl CapsArgs { + fn output_format(&self, global_json: bool) -> OutputFormat { + let local = match &self.command { + CapsCommands::List(args) => args.format, + CapsCommands::Get(args) | CapsCommands::Context(args) | CapsCommands::Status(args) => { + args.format + } + CapsCommands::Wait(args) => args.format, + CapsCommands::Process(args) => args.format, + CapsCommands::Import(args) => match &args.command { + ImportCommands::Loom(args) => args.format, + }, + CapsCommands::Transcript(_) | CapsCommands::Download(_) => OutputFormat::Text, + CapsCommands::TranscriptReplace(args) => args.format, + CapsCommands::Duplicate(args) | CapsCommands::Delete(args) => args.format, + CapsCommands::Password(args) => match &args.command { + PasswordCommands::Set(args) => args.format, + PasswordCommands::Clear(args) => args.format, + }, + CapsCommands::Unlock(args) => args.format, + CapsCommands::Comments(args) => match &args.command { + CommentsCommands::Add(args) => args.format, + CommentsCommands::Reply(args) => args.format, + CommentsCommands::Delete(args) => args.format, + }, + CapsCommands::Reactions(args) => match &args.command { + ReactionsCommands::Add(args) => args.format, + }, + CapsCommands::Update(args) => args.format, + CapsCommands::Sharing(args) => match &args.command { + SharingCommands::Set(args) => args.format, + }, + CapsCommands::Settings(args) => match &args.command { + SettingsCommands::Get(args) => args.format, + SettingsCommands::Set(args) => args.format, + }, + CapsCommands::Date(args) => match &args.command { + DateCommands::Set(args) => args.format, + }, + CapsCommands::Move(args) => args.format, + CapsCommands::Shares(args) => match &args.command { + ShareCommands::List(args) => args.format, + ShareCommands::Organization(args) => match &args.command { + OrganizationShareCommands::Add(args) => args.format, + OrganizationShareCommands::Remove(args) => args.format, + }, + ShareCommands::Space(args) => match &args.command { + SpaceShareCommands::Add(args) => args.format, + SpaceShareCommands::Remove(args) => args.format, + }, + }, + }; + resolve_format(global_json, local) + } + + pub async fn run(self, global_json: bool) -> Result<(), String> { + let output_format = self.output_format(global_json); + let result = self.run_inner(global_json).await; + if let Err(error) = &result + && output_format == OutputFormat::Json + { + let _ = write_json(error); + } + result.map_err(|error| error.to_string()) + } + + async fn run_inner(self, global_json: bool) -> Result<(), AgentApiError> { + let client = AgentClient::from_credentials()?; + match self.command { + CapsCommands::List(args) => { + let query = encode_query(&[ + ("scope", Some(args.scope.as_str().to_string())), + ("organizationId", args.organization), + ("folderId", args.folder), + ("search", args.search), + ("updatedAfter", args.updated_after), + ("cursor", args.cursor), + ("limit", Some(args.limit.to_string())), + ]); + let value = client.get_json(&format!("/caps?{query}")).await?; + let format = resolve_format(global_json, args.format); + if format == OutputFormat::Text { + let caps = value.get("caps").and_then(Value::as_array).ok_or_else(|| { + AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid list", + ) + })?; + for cap in caps { + println!( + "{}\t{}\t{}", + cap.get("id").and_then(Value::as_str).unwrap_or(""), + cap.get("title").and_then(Value::as_str).unwrap_or(""), + cap.get("updatedAt").and_then(Value::as_str).unwrap_or("") + ); + } + if let Some(cursor) = value.get("nextCursor").and_then(Value::as_str) { + println!("next cursor: {cursor}"); + } + Ok(()) + } else { + print_value(&value, format) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + } + CapsCommands::Get(args) => { + let id = cap_id(&args.cap)?; + let value = client.get_json(&format!("/caps/{id}")).await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Context(args) => { + let id = cap_id(&args.cap)?; + let value = client.get_json(&format!("/caps/{id}/context")).await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Status(args) => { + let id = cap_id(&args.cap)?; + let value = client.get_json(&format!("/caps/{id}/status")).await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Wait(args) => { + if args.timeout == 0 || args.timeout > 86_400 { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "--timeout must be between 1 and 86400 seconds", + )); + } + let id = cap_id(&args.cap)?; + let started = tokio::time::Instant::now(); + let deadline = started + Duration::from_secs(args.timeout); + let mut attempt = 0_u32; + loop { + let value = client.get_json(&format!("/caps/{id}/status")).await?; + if wait_complete(&value, args.r#for)? { + return print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| { + AgentApiError::local("TEMPORARY_UNAVAILABLE", message) + }); + } + if tokio::time::Instant::now() >= deadline { + return Err(AgentApiError { + code: "NOT_READY".to_string(), + message: "Timed out waiting for Cap processing".to_string(), + retryable: true, + retry_after_ms: Some(2_000), + request_id: value + .get("requestId") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + let base = 500_u64.saturating_mul(1_u64 << attempt.min(4)); + let jitter = u64::from(uuid::Uuid::new_v4().as_bytes()[0]); + tokio::time::sleep(Duration::from_millis((base + jitter).min(10_000))).await; + attempt = attempt.saturating_add(1); + } + } + CapsCommands::Process(args) => { + let id = cap_id(&args.cap)?; + confirmation::require( + args.yes, + "Start paid Cap processing when work is not already complete", + ) + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/process"), + &json!({ + "target": args.target.as_str(), + "retry": args.retry, + }), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Import(args) => match args.command { + ImportCommands::Loom(args) => { + if args.timeout == 0 || args.timeout > 86_400 { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "--timeout must be between 1 and 86400 seconds", + )); + } + confirmation::require( + args.yes, + if args.owner_email.is_some() || args.space.is_some() { + "Import the Loom video and apply the requested organization assignment" + } else { + "Import the Loom video into Cap" + }, + ) + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let organization = opaque_id(&args.organization, "Organization ID")?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{organization}/imports/loom"), + &json!({ + "loomUrl": args.loom_url, + "ownerEmail": args.owner_email, + "spaceName": args.space, + }), + ) + .await?; + let value = if args.wait { + let operation_id = + value.get("id").and_then(Value::as_str).ok_or_else(|| { + AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid Loom import operation", + ) + })?; + crate::jobs::wait_operation(&client, operation_id, args.timeout).await? + } else { + value + }; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + }, + CapsCommands::Transcript(args) => { + let id = cap_id(&args.cap)?; + let path = format!("/caps/{id}/transcript?format={}", args.format.as_str()); + let bytes = client.stream_authorized(&path, &args.output).await?; + let result = json!({ + "id": id, + "path": args.output, + "format": args.format.as_str(), + "bytes": bytes, + }); + print_value( + &result, + if global_json { + OutputFormat::Json + } else { + OutputFormat::Text + }, + ) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::TranscriptReplace(args) => { + let id = cap_id(&args.cap)?; + let metadata = tokio::fs::metadata(&args.input) + .await + .map_err(|error| AgentApiError::local("INVALID_REQUEST", error.to_string()))?; + if metadata.len() > 12 * 1024 * 1024 { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Transcript JSON must not exceed 12 MiB", + )); + } + let input = tokio::fs::read(&args.input) + .await + .map_err(|error| AgentApiError::local("INVALID_REQUEST", error.to_string()))?; + let document: Value = serde_json::from_slice(&input) + .map_err(|error| AgentApiError::local("INVALID_REQUEST", error.to_string()))?; + let expected_revision = args + .expected_revision + .or_else(|| { + document + .get("revision") + .and_then(Value::as_str) + .map(str::to_string) + }) + .ok_or_else(|| { + AgentApiError::local( + "INVALID_REQUEST", + "Provide --expected-revision or a transcript JSON revision", + ) + })?; + let cues = document.get("cues").cloned().ok_or_else(|| { + AgentApiError::local("INVALID_REQUEST", "Transcript JSON must contain cues") + })?; + confirmation::require(args.yes, "Replace the Cap transcript") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::PUT, + &format!("/caps/{id}/transcript"), + &json!({ + "expectedRevision": expected_revision, + "cues": cues, + }), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Download(args) => { + let id = cap_id(&args.cap)?; + let info = client.get_json(&format!("/caps/{id}/download")).await?; + let url = info.get("url").and_then(Value::as_str).ok_or_else(|| { + AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid download", + ) + })?; + let response = client.http.get(url).send().await.map_err(|error| { + AgentApiError::local("TEMPORARY_UNAVAILABLE", error.to_string()) + })?; + if !response.status().is_success() { + return Err(AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + format!("Download returned HTTP {}", response.status()), + )); + } + let bytes = stream_response(response, &args.output).await?; + let result = json!({ "id": id, "path": args.output, "bytes": bytes }); + print_value( + &result, + if global_json { + OutputFormat::Json + } else { + OutputFormat::Text + }, + ) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Duplicate(args) => { + run_cap_operation(&client, args, false, global_json).await + } + CapsCommands::Delete(args) => run_cap_operation(&client, args, true, global_json).await, + CapsCommands::Password(args) => { + let (value, format) = match args.command { + PasswordCommands::Set(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Set the Cap password") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let password = read_unlock_password(args.password_stdin)?; + let value = client + .mutate_text_confirmed( + Method::PUT, + &format!("/caps/{id}/password"), + &password, + ) + .await?; + drop(password); + (value, args.format) + } + PasswordCommands::Clear(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Clear the Cap password") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_text_confirmed(Method::PUT, &format!("/caps/{id}/password"), "") + .await?; + (value, args.format) + } + }; + print_value(&value, resolve_format(global_json, format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Unlock(args) => { + let id = cap_id(&args.cap)?; + let password = read_unlock_password(args.password_stdin)?; + let response = client.unlock_json(&id, &password).await?; + drop(password); + let access_grant = response + .get("accessGrant") + .and_then(Value::as_str) + .ok_or_else(|| { + AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid access grant", + ) + })?; + let expires_at = response + .get("expiresAt") + .and_then(Value::as_str) + .ok_or_else(|| { + AgentApiError::local( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid access grant expiry", + ) + })?; + credentials::store_agent_access_grant( + &id, + access_grant.to_string(), + expires_at.to_string(), + args.allow_file_credential, + ) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message))?; + print_value( + &json!({ "id": id, "unlocked": true, "expiresAt": expires_at }), + resolve_format(global_json, args.format), + ) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Comments(args) => { + let (value, format) = match args.command { + CommentsCommands::Add(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Post the comment") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/comments"), + &json!({ + "content": args.content, + "timestampMs": args.timestamp_ms, + }), + ) + .await?; + (value, args.format) + } + CommentsCommands::Reply(args) => { + let id = cap_id(&args.cap)?; + let comment_id = opaque_id(&args.comment_id, "Comment ID")?; + confirmation::require(args.yes, "Post the reply") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/comments/{comment_id}/replies"), + &json!({ + "content": args.content, + "timestampMs": args.timestamp_ms, + }), + ) + .await?; + (value, args.format) + } + CommentsCommands::Delete(args) => { + let id = cap_id(&args.cap)?; + let comment_id = opaque_id(&args.comment_id, "Comment ID")?; + confirmation::require(args.yes, "Delete the comment") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json( + Method::DELETE, + &format!("/caps/{id}/comments/{comment_id}"), + &json!({}), + ) + .await?; + (value, args.format) + } + }; + print_value(&value, resolve_format(global_json, format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Reactions(args) => { + let (value, format) = match args.command { + ReactionsCommands::Add(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Post the reaction") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/reactions"), + &json!({ + "content": args.reaction, + "timestampMs": args.timestamp_ms, + }), + ) + .await?; + (value, args.format) + } + }; + print_value(&value, resolve_format(global_json, format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Update(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Change the Cap title") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::PATCH, + &format!("/caps/{id}"), + &json!({ "title": args.title }), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Sharing(args) => { + let (value, format) = match args.command { + SharingCommands::Set(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Change the Cap visibility") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json_confirmed( + Method::PATCH, + &format!("/caps/{id}"), + &json!({ "public": args.public && !args.private }), + ) + .await?; + (value, args.format) + } + }; + print_value(&value, resolve_format(global_json, format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Settings(args) => match args.command { + SettingsCommands::Get(args) => { + let id = cap_id(&args.cap)?; + let value = client.get_json(&format!("/caps/{id}/settings")).await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + SettingsCommands::Set(args) => { + let id = cap_id(&args.cap)?; + let mut body = Map::new(); + for (key, value) in [ + ("disableSummary", args.disable_summary), + ("disableCaptions", args.disable_captions), + ("disableChapters", args.disable_chapters), + ("disableReactions", args.disable_reactions), + ("disableTranscript", args.disable_transcript), + ("disableComments", args.disable_comments), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + if let Some(value) = args.default_playback_speed { + body.insert("defaultPlaybackSpeed".to_string(), json!(value)); + } + if body.is_empty() { + return Err(AgentApiError::local( + "INVALID_REQUEST", + "Provide at least one Cap setting", + )); + } + confirmation::require(args.yes, "Update the Cap viewer settings") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json( + Method::PATCH, + &format!("/caps/{id}/settings"), + &Value::Object(body), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + }, + CapsCommands::Date(args) => match args.command { + DateCommands::Set(args) => { + let id = cap_id(&args.cap)?; + confirmation::require(args.yes, "Change the Cap recording date") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json( + Method::PATCH, + &format!("/caps/{id}/date"), + &json!({ "createdAt": args.created_at }), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + }, + CapsCommands::Move(args) => { + let id = cap_id(&args.cap)?; + let organization_id = opaque_id(&args.organization, "Organization ID")?; + let space_id = args + .space + .as_deref() + .map(|space| opaque_id(space, "Space ID")) + .transpose()?; + let folder_id = if args.root { + None + } else { + args.folder + .as_deref() + .map(|folder| opaque_id(folder, "Folder ID")) + .transpose()? + }; + confirmation::require(args.yes, "Move the Cap") + .map_err(|message| AgentApiError::local("INVALID_REQUEST", message))?; + let value = client + .mutate_json( + Method::PATCH, + &format!("/caps/{id}/location"), + &json!({ + "container": args.container.as_str(), + "organizationId": organization_id, + "spaceId": space_id, + "folderId": folder_id, + }), + ) + .await?; + print_value(&value, resolve_format(global_json, args.format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + CapsCommands::Shares(args) => { + let (value, format) = match args.command { + ShareCommands::List(args) => { + let id = cap_id(&args.cap)?; + let value = client.get_json(&format!("/caps/{id}/shares")).await?; + (value, args.format) + } + ShareCommands::Organization(args) => match args.command { + OrganizationShareCommands::Add(args) => { + let id = cap_id(&args.cap)?; + let organization = opaque_id(&args.organization, "Organization ID")?; + let folder = args + .folder + .as_deref() + .map(|value| opaque_id(value, "Folder ID")) + .transpose()?; + confirmation::require(args.yes, "Share the Cap with the organization") + .map_err(|message| { + AgentApiError::local("INVALID_REQUEST", message) + })?; + let value = client + .mutate_json( + Method::PUT, + &format!("/caps/{id}/shares/organizations/{organization}"), + &json!({ "folderId": folder }), + ) + .await?; + (value, args.format) + } + OrganizationShareCommands::Remove(args) => { + let id = cap_id(&args.cap)?; + let organization = opaque_id(&args.organization, "Organization ID")?; + confirmation::require(args.yes, "Remove the organization share") + .map_err(|message| { + AgentApiError::local("INVALID_REQUEST", message) + })?; + let value = client + .mutate_json( + Method::DELETE, + &format!("/caps/{id}/shares/organizations/{organization}"), + &json!({}), + ) + .await?; + (value, args.format) + } + }, + ShareCommands::Space(args) => match args.command { + SpaceShareCommands::Add(args) => { + let id = cap_id(&args.cap)?; + let space = opaque_id(&args.space, "Space ID")?; + let folder = args + .folder + .as_deref() + .map(|value| opaque_id(value, "Folder ID")) + .transpose()?; + confirmation::require(args.yes, "Share the Cap with the space") + .map_err(|message| { + AgentApiError::local("INVALID_REQUEST", message) + })?; + let value = client + .mutate_json( + Method::PUT, + &format!("/caps/{id}/shares/spaces/{space}"), + &json!({ "folderId": folder }), + ) + .await?; + (value, args.format) + } + SpaceShareCommands::Remove(args) => { + let id = cap_id(&args.cap)?; + let space = opaque_id(&args.space, "Space ID")?; + confirmation::require(args.yes, "Remove the space share").map_err( + |message| AgentApiError::local("INVALID_REQUEST", message), + )?; + let value = client + .mutate_json( + Method::DELETE, + &format!("/caps/{id}/shares/spaces/{space}"), + &json!({}), + ) + .await?; + (value, args.format) + } + }, + }; + print_value(&value, resolve_format(global_json, format)) + .map_err(|message| AgentApiError::local("TEMPORARY_UNAVAILABLE", message)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn read_request(stream: &mut tokio::net::TcpStream) -> String { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1_024]; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if let Some(headers_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..headers_end + 4]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + if bytes.len() >= headers_end + 4 + content_length { + break; + } + } + } + String::from_utf8(bytes).unwrap() + } + + async fn mock_response(stream: &mut tokio::net::TcpStream, status: &str, body: &str) { + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + } + + #[test] + fn accepts_ids_and_known_cap_urls() { + assert_eq!(cap_id("cap_12345").unwrap(), "cap_12345"); + assert_eq!(cap_id("https://cap.so/s/cap_12345").unwrap(), "cap_12345"); + assert_eq!( + cap_id("https://videos.example.com/cap_12345").unwrap(), + "cap_12345" + ); + assert!(cap_id("https://cap.so/settings/billing").is_err()); + } + + #[test] + fn terminal_wait_states_do_not_start_work() { + assert!( + wait_complete( + &json!({"transcript":{"status":"complete"}}), + WaitFor::Transcript + ) + .unwrap() + ); + assert!(wait_complete(&json!({"ai":{"status":"skipped"}}), WaitFor::Ai).unwrap()); + assert!( + wait_complete(&json!({"ai":{"status":"processing"}}), WaitFor::Ai) + .is_ok_and(|done| !done) + ); + } + + #[tokio::test] + async fn mutation_retries_reuse_the_same_idempotency_key() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut keys = Vec::new(); + for attempt in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + let key = request + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("idempotency-key:") + .map(str::trim) + .map(str::to_string) + }) + .unwrap(); + keys.push(key); + if attempt == 0 { + mock_response( + &mut stream, + "503 Service Unavailable", + r#"{"code":"TEMPORARY_UNAVAILABLE","message":"retry","retryable":true,"retryAfterMs":1,"requestId":"request_1"}"#, + ) + .await; + } else { + mock_response(&mut stream, "200 OK", r#"{"ok":true}"#).await; + } + } + keys + }); + let client = AgentClient::new(format!("http://{address}"), "token".to_string()).unwrap(); + let value = client + .mutate_json( + Method::POST, + "/caps/cap_test/comments", + &json!({"content":"ok"}), + ) + .await + .unwrap(); + assert_eq!(value["ok"], true); + let keys = server.await.unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], keys[1]); + } + + #[tokio::test] + async fn mutation_transport_retries_reuse_the_same_idempotency_key() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut keys = Vec::new(); + for attempt in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + keys.push( + request + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("idempotency-key:") + .map(str::trim) + .map(str::to_string) + }) + .unwrap(), + ); + if attempt == 1 { + mock_response(&mut stream, "200 OK", r#"{"ok":true}"#).await; + } + } + keys + }); + let client = AgentClient::new(format!("http://{address}"), "token".to_string()).unwrap(); + let value = client + .mutate_json( + Method::POST, + "/caps/cap_test/comments", + &json!({"content":"ok"}), + ) + .await + .unwrap(); + assert_eq!(value["ok"], true); + let keys = server.await.unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], keys[1]); + } + + #[tokio::test] + async fn access_grants_are_sent_only_as_headers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + mock_response(&mut stream, "200 OK", r#"{"ok":true}"#).await; + request + }); + let client = AgentClient::new(format!("http://{address}"), "token".to_string()).unwrap(); + client.access_grants.write().unwrap().insert( + "cap_test".to_string(), + credentials::AgentAccessGrant { + value: "encrypted_access_grant".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(1), + }, + ); + client.get_json("/caps/cap_test/context").await.unwrap(); + let request = server.await.unwrap(); + assert!(request.contains("x-cap-access-grant: encrypted_access_grant")); + assert!(!request.contains("password")); + } +} From e5f7abe7bf912343bdc57affa4b6c9279de2d61c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 33/58] feat(cli): add library commands --- apps/cli/src/library.rs | 735 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 apps/cli/src/library.rs diff --git a/apps/cli/src/library.rs b/apps/cli/src/library.rs new file mode 100644 index 00000000000..07f676cf39e --- /dev/null +++ b/apps/cli/src/library.rs @@ -0,0 +1,735 @@ +use std::path::PathBuf; + +use clap::{ArgGroup, Args, Subcommand, ValueEnum}; +use reqwest::Method; +use serde_json::{Map, Value, json}; + +use crate::{ + OutputFormat, + agent_client::{self, SpaceRole}, + caps::opaque_id, + confirmation, +}; + +#[derive(Args)] +pub struct LibraryArgs { + #[command(subcommand)] + command: LibraryCommands, +} + +#[derive(Subcommand)] +enum LibraryCommands { + Folders(FoldersArgs), + Spaces(SpacesArgs), +} + +#[derive(Args)] +struct FoldersArgs { + #[command(subcommand)] + command: FolderCommands, +} + +#[derive(Subcommand)] +enum FolderCommands { + List(FolderListArgs), + Create(FolderCreateArgs), + Update(FolderUpdateArgs), + PublicPage(CollectionPublicPageArgs), + Logo(CollectionLogoArgs), + Delete(FolderDeleteArgs), +} + +#[derive(Clone, Copy, ValueEnum)] +enum FolderColor { + Normal, + Blue, + Red, + Yellow, +} + +impl FolderColor { + const fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Blue => "blue", + Self::Red => "red", + Self::Yellow => "yellow", + } + } +} + +#[derive(Args)] +struct FolderListArgs { + organization: String, + #[arg(long)] + space: Option, + #[arg(long)] + parent: Option, + #[arg(long)] + root: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct FolderCreateArgs { + organization: String, + name: String, + #[arg(long, value_enum, default_value_t = FolderColor::Normal)] + color: FolderColor, + #[arg(long)] + parent: Option, + #[arg(long)] + space: Option, + #[arg(long)] + public: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +#[command(group(ArgGroup::new("parent_action").args(["parent", "root"])))] +struct FolderUpdateArgs { + folder: String, + #[arg(long)] + name: Option, + #[arg(long, value_enum)] + color: Option, + #[arg(long)] + parent: Option, + #[arg(long)] + root: bool, + #[arg(long)] + public: Option, + #[arg(long)] + settings_json: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct FolderDeleteArgs { + folder: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpacesArgs { + #[command(subcommand)] + command: SpaceCommands, +} + +#[derive(Subcommand)] +enum SpaceCommands { + List(SpaceListArgs), + Create(SpaceCreateArgs), + Update(SpaceUpdateArgs), + PublicPage(CollectionPublicPageArgs), + Logo(CollectionLogoArgs), + Delete(SpaceDeleteArgs), + Members(SpaceMembersArgs), +} + +#[derive(Clone, Copy, ValueEnum)] +enum CollectionLogoMode { + Cap, + Organization, + Custom, + None, +} + +impl CollectionLogoMode { + const fn as_str(self) -> &'static str { + match self { + Self::Cap => "cap", + Self::Organization => "organization", + Self::Custom => "custom", + Self::None => "none", + } + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum CollectionLayout { + Grid, + List, +} + +impl CollectionLayout { + const fn as_str(self) -> &'static str { + match self { + Self::Grid => "grid", + Self::List => "list", + } + } +} + +#[derive(Args)] +struct CollectionPublicPageArgs { + collection: String, + #[arg(long)] + public: Option, + #[arg(long)] + title: Option, + #[arg(long)] + subtitle: Option, + #[arg(long)] + hide_title: Option, + #[arg(long)] + hide_copy_link: Option, + #[arg(long, value_enum)] + logo_mode: Option, + #[arg(long)] + cta_label: Option, + #[arg(long)] + cta_url: Option, + #[arg(long, value_enum)] + layout: Option, + #[arg(long)] + grid_columns: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct CollectionLogoArgs { + #[command(subcommand)] + command: CollectionLogoCommands, +} + +#[derive(Subcommand)] +enum CollectionLogoCommands { + Set(CollectionLogoSetArgs), + Remove(CollectionLogoRemoveArgs), +} + +#[derive(Args)] +struct CollectionLogoSetArgs { + collection: String, + image: PathBuf, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct CollectionLogoRemoveArgs { + collection: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum SpacePrivacy { + Public, + Private, +} + +impl SpacePrivacy { + const fn as_str(self) -> &'static str { + match self { + Self::Public => "Public", + Self::Private => "Private", + } + } +} + +#[derive(Args)] +struct SpaceListArgs { + organization: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args, Default)] +struct ViewerSettingsArgs { + #[arg(long)] + disable_summary: Option, + #[arg(long)] + disable_captions: Option, + #[arg(long)] + disable_chapters: Option, + #[arg(long)] + disable_reactions: Option, + #[arg(long)] + disable_transcript: Option, + #[arg(long)] + disable_comments: Option, +} + +impl ViewerSettingsArgs { + fn value(&self) -> Option { + let mut settings = Map::new(); + for (key, value) in [ + ("disableSummary", self.disable_summary), + ("disableCaptions", self.disable_captions), + ("disableChapters", self.disable_chapters), + ("disableReactions", self.disable_reactions), + ("disableTranscript", self.disable_transcript), + ("disableComments", self.disable_comments), + ] { + if let Some(value) = value { + settings.insert(key.to_string(), Value::Bool(value)); + } + } + (!settings.is_empty()).then_some(Value::Object(settings)) + } +} + +#[derive(Args)] +struct SpaceCreateArgs { + organization: String, + name: String, + #[arg(long)] + description: Option, + #[arg(long, value_enum, default_value_t = SpacePrivacy::Private)] + privacy: SpacePrivacy, + #[arg(long)] + public: bool, + #[command(flatten)] + settings: ViewerSettingsArgs, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceUpdateArgs { + space: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + #[arg(long)] + clear_description: bool, + #[arg(long, value_enum)] + privacy: Option, + #[arg(long)] + public: Option, + #[command(flatten)] + settings: ViewerSettingsArgs, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceDeleteArgs { + space: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceMembersArgs { + #[command(subcommand)] + command: SpaceMemberCommands, +} + +#[derive(Subcommand)] +enum SpaceMemberCommands { + List(SpaceMemberListArgs), + Add(SpaceMemberAddArgs), + Update(SpaceMemberUpdateArgs), + Remove(SpaceMemberRemoveArgs), +} + +#[derive(Args)] +struct SpaceMemberListArgs { + space: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceMemberAddArgs { + space: String, + user: String, + #[arg(long, value_enum, default_value_t = SpaceRole::Member)] + role: SpaceRole, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceMemberUpdateArgs { + space: String, + user: String, + #[arg(long, value_enum)] + role: SpaceRole, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct SpaceMemberRemoveArgs { + space: String, + user: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +fn add_optional(body: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + body.insert(key.to_string(), value); + } +} + +async fn update_collection_public_page( + kind: &str, + args: CollectionPublicPageArgs, + global_json: bool, +) -> Result<(), String> { + confirmation::require(args.yes, "Update the public collection page")?; + if let Some(columns) = args.grid_columns + && !matches!(columns, 2..=5) + { + return Err("--grid-columns must be between 2 and 5".to_string()); + } + let id = opaque_id(&args.collection, "Collection ID").map_err(|error| error.to_string())?; + let mut body = Map::new(); + add_optional(&mut body, "public", args.public.map(Value::Bool)); + add_optional(&mut body, "title", args.title.map(Value::String)); + add_optional(&mut body, "subtitle", args.subtitle.map(Value::String)); + add_optional(&mut body, "hideTitle", args.hide_title.map(Value::Bool)); + add_optional( + &mut body, + "hideCopyLink", + args.hide_copy_link.map(Value::Bool), + ); + add_optional( + &mut body, + "logoMode", + args.logo_mode + .map(|mode| Value::String(mode.as_str().to_string())), + ); + add_optional(&mut body, "ctaLabel", args.cta_label.map(Value::String)); + add_optional(&mut body, "ctaUrl", args.cta_url.map(Value::String)); + add_optional( + &mut body, + "layout", + args.layout + .map(|layout| Value::String(layout.as_str().to_string())), + ); + add_optional(&mut body, "gridColumns", args.grid_columns.map(Value::from)); + if body.is_empty() { + return Err("Provide at least one public page update".to_string()); + } + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/{kind}/{id}/public-page"), + &Value::Object(body), + global_json, + args.format, + ) + .await +} + +async fn update_collection_logo( + kind: &str, + args: CollectionLogoArgs, + global_json: bool, +) -> Result<(), String> { + match args.command { + CollectionLogoCommands::Set(args) => { + confirmation::require(args.yes, "Update the public collection logo")?; + let id = + opaque_id(&args.collection, "Collection ID").map_err(|error| error.to_string())?; + let body = agent_client::image_payload(&args.image)?; + agent_client::mutate_confirmed( + Method::PUT, + &format!("/{kind}/{id}/logo"), + &body, + global_json, + args.format, + ) + .await + } + CollectionLogoCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the public collection logo")?; + let id = + opaque_id(&args.collection, "Collection ID").map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/{kind}/{id}/logo"), + &json!({}), + global_json, + args.format, + ) + .await + } + } +} + +impl LibraryArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + match self.command { + LibraryCommands::Folders(args) => run_folders(args, global_json).await, + LibraryCommands::Spaces(args) => run_spaces(args, global_json).await, + } + } +} + +async fn run_folders(args: FoldersArgs, global_json: bool) -> Result<(), String> { + match args.command { + FolderCommands::List(args) => { + let organization = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let parent = if args.root { + Some("root".to_string()) + } else { + args.parent + }; + let query = agent_client::query(&[("spaceId", args.space), ("parentId", parent)]); + agent_client::read( + &format!("/organizations/{organization}/folders?{query}"), + global_json, + args.format, + ) + .await + } + FolderCommands::Create(args) => { + let organization = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Create the folder")?; + agent_client::mutate( + Method::POST, + &format!("/organizations/{organization}/folders"), + &json!({ + "name": args.name, + "color": args.color.as_str(), + "parentId": args.parent, + "spaceId": args.space, + "public": args.public, + }), + global_json, + args.format, + ) + .await + } + FolderCommands::Update(args) => { + let folder = opaque_id(&args.folder, "Folder ID").map_err(|error| error.to_string())?; + let mut body = Map::new(); + add_optional(&mut body, "name", args.name.map(Value::String)); + add_optional( + &mut body, + "color", + args.color + .map(|color| Value::String(color.as_str().to_string())), + ); + if args.root { + body.insert("parentId".to_string(), Value::Null); + } else { + add_optional(&mut body, "parentId", args.parent.map(Value::String)); + } + add_optional(&mut body, "public", args.public.map(Value::Bool)); + if let Some(settings) = args.settings_json { + let value: Value = serde_json::from_str(&settings) + .map_err(|error| format!("Invalid --settings-json: {error}"))?; + if !value.is_object() { + return Err("--settings-json must be a JSON object".to_string()); + } + body.insert("settings".to_string(), value); + } + if body.is_empty() { + return Err("Provide at least one folder update".to_string()); + } + confirmation::require(args.yes, "Update the folder")?; + agent_client::mutate( + Method::PATCH, + &format!("/folders/{folder}"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + FolderCommands::PublicPage(args) => { + update_collection_public_page("folders", args, global_json).await + } + FolderCommands::Logo(args) => update_collection_logo("folders", args, global_json).await, + FolderCommands::Delete(args) => { + let folder = opaque_id(&args.folder, "Folder ID").map_err(|error| error.to_string())?; + confirmation::require( + args.yes, + "Delete the folder and move its Caps to the parent", + )?; + agent_client::mutate( + Method::DELETE, + &format!("/folders/{folder}"), + &json!({}), + global_json, + args.format, + ) + .await + } + } +} + +async fn run_spaces(args: SpacesArgs, global_json: bool) -> Result<(), String> { + match args.command { + SpaceCommands::List(args) => { + let organization = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read( + &format!("/organizations/{organization}/spaces"), + global_json, + args.format, + ) + .await + } + SpaceCommands::Create(args) => { + let organization = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Create the space")?; + let mut body = Map::from_iter([ + ("name".to_string(), Value::String(args.name)), + ( + "privacy".to_string(), + Value::String(args.privacy.as_str().to_string()), + ), + ("public".to_string(), Value::Bool(args.public)), + ]); + add_optional( + &mut body, + "description", + args.description.map(Value::String), + ); + add_optional(&mut body, "settings", args.settings.value()); + agent_client::mutate( + Method::POST, + &format!("/organizations/{organization}/spaces"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + SpaceCommands::Update(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + let mut body = Map::new(); + add_optional(&mut body, "name", args.name.map(Value::String)); + if args.clear_description { + body.insert("description".to_string(), Value::Null); + } else { + add_optional( + &mut body, + "description", + args.description.map(Value::String), + ); + } + add_optional( + &mut body, + "privacy", + args.privacy + .map(|privacy| Value::String(privacy.as_str().to_string())), + ); + add_optional(&mut body, "public", args.public.map(Value::Bool)); + add_optional(&mut body, "settings", args.settings.value()); + if body.is_empty() { + return Err("Provide at least one space update".to_string()); + } + confirmation::require(args.yes, "Update the space")?; + agent_client::mutate( + Method::PATCH, + &format!("/spaces/{space}"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + SpaceCommands::PublicPage(args) => { + update_collection_public_page("spaces", args, global_json).await + } + SpaceCommands::Logo(args) => update_collection_logo("spaces", args, global_json).await, + SpaceCommands::Delete(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Permanently delete the space")?; + agent_client::mutate( + Method::DELETE, + &format!("/spaces/{space}"), + &json!({}), + global_json, + args.format, + ) + .await + } + SpaceCommands::Members(args) => run_space_members(args, global_json).await, + } +} + +async fn run_space_members(args: SpaceMembersArgs, global_json: bool) -> Result<(), String> { + match args.command { + SpaceMemberCommands::List(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + agent_client::read( + &format!("/spaces/{space}/members"), + global_json, + args.format, + ) + .await + } + SpaceMemberCommands::Add(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + let user = opaque_id(&args.user, "User ID").map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Add the space member")?; + agent_client::mutate( + Method::POST, + &format!("/spaces/{space}/members"), + &json!({ "userId": user, "role": args.role.as_str() }), + global_json, + args.format, + ) + .await + } + SpaceMemberCommands::Update(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + let user = opaque_id(&args.user, "User ID").map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Change the space member role")?; + agent_client::mutate( + Method::PATCH, + &format!("/spaces/{space}/members/{user}"), + &json!({ "role": args.role.as_str() }), + global_json, + args.format, + ) + .await + } + SpaceMemberCommands::Remove(args) => { + let space = opaque_id(&args.space, "Space ID").map_err(|error| error.to_string())?; + let user = opaque_id(&args.user, "User ID").map_err(|error| error.to_string())?; + confirmation::require(args.yes, "Remove the space member")?; + agent_client::mutate( + Method::DELETE, + &format!("/spaces/{space}/members/{user}"), + &json!({}), + global_json, + args.format, + ) + .await + } + } +} From c4b437330a7a0d2f70fcfc52907e644c524920b1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 34/58] feat(cli): extend upload for agent API --- apps/cli/src/upload.rs | 254 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 228 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/upload.rs b/apps/cli/src/upload.rs index 8c4c818a4b8..fc9d49a6a9e 100644 --- a/apps/cli/src/upload.rs +++ b/apps/cli/src/upload.rs @@ -2,12 +2,12 @@ use std::path::{Path, PathBuf}; use cap_project::RecordingMeta; use clap::Args; -use reqwest::Client; +use reqwest::{Client, Method}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio_util::io::ReaderStream; -use crate::{OutputFormat, credentials, export, resolve_format, write_json}; +use crate::{OutputFormat, caps::AgentClient, credentials, export, resolve_format, write_json}; #[derive(Args)] pub struct UploadArgs { @@ -39,6 +39,56 @@ struct VideoMeta { fps: f64, } +fn prefer_agent_upload_sources( + agent: credentials::AgentCredentialSource, + legacy: Option, +) -> bool { + match agent { + credentials::AgentCredentialSource::Env => true, + credentials::AgentCredentialSource::Keyring | credentials::AgentCredentialSource::File => { + legacy != Some(credentials::CredentialSource::Env) + } + credentials::AgentCredentialSource::LegacyEnv + | credentials::AgentCredentialSource::Desktop => false, + } +} + +fn prefer_agent_upload() -> bool { + let Ok(agent) = credentials::resolve_agent() else { + return false; + }; + prefer_agent_upload_sources( + agent.source, + credentials::resolve().ok().map(|legacy| legacy.source), + ) +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum AgentUploadTarget { + Put { + url: String, + headers: std::collections::BTreeMap, + }, + DriveResumable { + url: String, + headers: std::collections::BTreeMap, + }, + S3Post { + url: String, + fields: std::collections::BTreeMap, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentUploadResponse { + id: String, + share_url: String, + raw_file_key: String, + upload: AgentUploadTarget, +} + fn rational_fps(rate: ffmpeg::Rational) -> Option { (rate.denominator() != 0 && rate.numerator() > 0) .then(|| f64::from(rate.numerator()) / f64::from(rate.denominator())) @@ -64,23 +114,46 @@ impl UploadArgs { } async fn run_inner(self, format: OutputFormat) -> Result<(), String> { - // Resolves CAP_API_KEY, else the login Cap Desktop already stored, so an agent never has to - // fetch or paste a key when the user is signed in. - let creds = credentials::resolve()?; - let server = creds.server.clone(); - let file_path = self.resolve_upload_file().await?; let meta = probe_video_meta(&file_path)?; - - let http = Client::new(); - let auth = format!("Bearer {}", creds.api_key); - - let video_id = - create_video(&http, &server, &auth, &self.name, self.video_id, &meta).await?; - let put_url = presign_put(&http, &server, &auth, &video_id, &meta).await?; - upload_file(&http, &put_url, &file_path).await?; - - let link = format!("{server}/s/{video_id}"); + let (video_id, link) = if prefer_agent_upload() { + upload_file_with_agent(&file_path, self.name.as_deref(), &meta) + .await + .map_err(|error| { + format!( + "{error} Run `cap auth login` or sign in to Cap Desktop, or set CAP_API_KEY/CAP_AGENT_TOKEN." + ) + })? + } else { + match credentials::resolve() { + Ok(creds) => { + let server = creds.server.clone(); + let http = Client::new(); + let auth = format!("Bearer {}", creds.api_key); + let video_id = + create_video(&http, &server, &auth, &self.name, self.video_id, &meta) + .await?; + let put_url = presign_put(&http, &server, &auth, &video_id, &meta).await?; + upload_file(&http, &put_url, &file_path).await?; + let link = format!("{server}/s/{video_id}"); + (video_id, link) + } + Err(legacy_error) => { + if self.video_id.is_some() { + return Err(format!( + "{legacy_error} --video-id currently requires Cap Desktop or CAP_API_KEY authentication" + )); + } + upload_file_with_agent(&file_path, self.name.as_deref(), &meta) + .await + .map_err(|error| { + format!( + "{error} Run `cap auth login` or sign in to Cap Desktop, or set CAP_API_KEY/CAP_AGENT_TOKEN." + ) + })? + } + } + }; let is_project = self.file.is_dir() || self @@ -153,18 +226,109 @@ pub async fn upload_video_path(file_path: &Path, name: Option) -> Result )); } - let creds = credentials::resolve()?; - let server = creds.server.clone(); let meta = probe_video_meta(file_path)?; + if prefer_agent_upload() { + return upload_file_with_agent(file_path, name.as_deref(), &meta) + .await + .map(|(_, link)| link); + } + match credentials::resolve() { + Ok(creds) => { + let server = creds.server.clone(); + let http = Client::new(); + let auth = format!("Bearer {}", creds.api_key); + let video_id = create_video(&http, &server, &auth, &name, None, &meta).await?; + let put_url = presign_put(&http, &server, &auth, &video_id, &meta).await?; + upload_file(&http, &put_url, file_path).await?; + Ok(format!("{server}/s/{video_id}")) + } + Err(_) => upload_file_with_agent(file_path, name.as_deref(), &meta) + .await + .map(|(_, link)| link), + } +} - let http = Client::new(); - let auth = format!("Bearer {}", creds.api_key); - - let video_id = create_video(&http, &server, &auth, &name, None, &meta).await?; - let put_url = presign_put(&http, &server, &auth, &video_id, &meta).await?; - upload_file(&http, &put_url, file_path).await?; +async fn upload_file_with_agent( + file_path: &Path, + name: Option<&str>, + meta: &VideoMeta, +) -> Result<(String, String), String> { + let content_length = tokio::fs::metadata(file_path) + .await + .map_err(|error| format!("Failed to read {}: {error}", file_path.display()))? + .len(); + let file_name = file_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("upload.mp4"); + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let response = client + .mutate_json( + Method::POST, + "/uploads", + &json!({ + "fileName": file_name, + "contentType": "video/mp4", + "contentLength": content_length, + "durationSeconds": meta.duration_in_secs, + "width": meta.width, + "height": meta.height, + "fps": meta.fps, + "title": name, + }), + ) + .await + .map_err(|error| error.to_string())?; + let upload: AgentUploadResponse = serde_json::from_value(response) + .map_err(|error| format!("Unexpected agent upload response: {error}"))?; + upload_agent_target(&Client::new(), &upload.upload, file_path, content_length).await?; + client + .mutate_json( + Method::POST, + &format!("/uploads/{}/complete", upload.id), + &json!({ + "rawFileKey": upload.raw_file_key, + "contentLength": content_length, + }), + ) + .await + .map_err(|error| error.to_string())?; + Ok((upload.id, upload.share_url)) +} - Ok(format!("{server}/s/{video_id}")) +async fn upload_agent_target( + http: &Client, + target: &AgentUploadTarget, + path: &Path, + content_length: u64, +) -> Result<(), String> { + let (url, headers) = match target { + AgentUploadTarget::Put { url, headers } + | AgentUploadTarget::DriveResumable { url, headers } => (url, headers), + AgentUploadTarget::S3Post { url, fields } => { + let _ = (url, fields); + return Err("Cap returned an unsupported S3 POST upload target".to_string()); + } + }; + let file = tokio::fs::File::open(path) + .await + .map_err(|error| format!("Failed to open {}: {error}", path.display()))?; + let mut request = http + .put(url) + .header(reqwest::header::CONTENT_LENGTH, content_length) + .body(reqwest::Body::wrap_stream(ReaderStream::new(file))); + for (name, value) in headers { + request = request.header(name, value); + } + let response = request + .send() + .await + .map_err(|error| format!("Upload failed: {error}"))?; + if response.status().is_success() { + Ok(()) + } else { + Err(format!("Storage upload failed ({})", response.status())) + } } fn probe_video_meta(path: &Path) -> Result { @@ -352,3 +516,41 @@ async fn upload_file(http: &Client, put_url: &str, path: &Path) -> Result<(), St Ok(()) } + +#[cfg(test)] +mod tests { + use super::prefer_agent_upload_sources; + use crate::credentials::{AgentCredentialSource, CredentialSource}; + + #[test] + fn explicit_agent_environment_credential_has_highest_upload_priority() { + assert!(prefer_agent_upload_sources( + AgentCredentialSource::Env, + Some(CredentialSource::Env), + )); + } + + #[test] + fn explicit_legacy_environment_credential_precedes_stored_agent_credential() { + assert!(!prefer_agent_upload_sources( + AgentCredentialSource::Keyring, + Some(CredentialSource::Env), + )); + } + + #[test] + fn stored_agent_credential_precedes_desktop_credential() { + assert!(prefer_agent_upload_sources( + AgentCredentialSource::File, + Some(CredentialSource::Desktop), + )); + } + + #[test] + fn legacy_agent_resolution_uses_legacy_upload() { + assert!(!prefer_agent_upload_sources( + AgentCredentialSource::Desktop, + Some(CredentialSource::Desktop), + )); + } +} From ff2a04ad3268477fa398b43e177961436bc7e600 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 35/58] feat(cli): add jobs commands --- apps/cli/src/jobs.rs | 126 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 apps/cli/src/jobs.rs diff --git a/apps/cli/src/jobs.rs b/apps/cli/src/jobs.rs new file mode 100644 index 00000000000..75373e964f7 --- /dev/null +++ b/apps/cli/src/jobs.rs @@ -0,0 +1,126 @@ +use std::time::Duration; + +use clap::{Args, Subcommand}; +use serde_json::Value; + +use crate::{ + OutputFormat, + agent_client::print_value, + caps::{AgentApiError, AgentClient, opaque_id}, + resolve_format, +}; + +#[derive(Args)] +pub struct JobsArgs { + #[command(subcommand)] + command: JobsCommands, +} + +#[derive(Subcommand)] +enum JobsCommands { + Get(JobTargetArgs), + Wait(JobWaitArgs), +} + +#[derive(Args)] +struct JobTargetArgs { + operation_id: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct JobWaitArgs { + operation_id: String, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +fn local_error(code: &str, message: impl Into) -> AgentApiError { + AgentApiError { + code: code.to_string(), + message: message.into(), + retryable: false, + retry_after_ms: None, + request_id: None, + } +} + +pub async fn wait_operation( + client: &AgentClient, + operation_id: &str, + timeout: u64, +) -> Result { + if timeout == 0 || timeout > 86_400 { + return Err(local_error( + "INVALID_REQUEST", + "--timeout must be between 1 and 86400 seconds", + )); + } + let operation_id = opaque_id(operation_id, "Operation ID")?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + let mut attempt = 0_u32; + loop { + let operation = client + .get_json(&format!("/operations/{operation_id}")) + .await?; + match operation.get("state").and_then(Value::as_str) { + Some("succeeded") => return Ok(operation), + Some("failed") => { + let message = operation + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or("Cap operation failed"); + return Err(local_error("OPERATION_FAILED", message)); + } + Some("queued" | "running") => {} + _ => { + return Err(local_error( + "TEMPORARY_UNAVAILABLE", + "Cap returned an invalid operation state", + )); + } + } + if tokio::time::Instant::now() >= deadline { + return Err(AgentApiError { + code: "NOT_READY".to_string(), + message: "Timed out waiting for the Cap operation".to_string(), + retryable: true, + retry_after_ms: Some(2_000), + request_id: operation + .get("requestId") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + let delay = 500_u64.saturating_mul(1_u64 << attempt.min(4)); + tokio::time::sleep(Duration::from_millis(delay.min(10_000))).await; + attempt = attempt.saturating_add(1); + } +} + +impl JobsArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + match self.command { + JobsCommands::Get(args) => { + let operation_id = opaque_id(&args.operation_id, "Operation ID") + .map_err(|error| error.to_string())?; + let value = client + .get_json(&format!("/operations/{operation_id}")) + .await + .map_err(|error| error.to_string())?; + print_value(&value, resolve_format(global_json, args.format)) + } + JobsCommands::Wait(args) => { + let value = wait_operation(&client, &args.operation_id, args.timeout) + .await + .map_err(|error| error.to_string())?; + print_value(&value, resolve_format(global_json, args.format)) + } + } + } +} From b7b0214760d000e65e1584def27202fc6be93141 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 36/58] feat(cli): add agents management commands --- apps/cli/src/agents.rs | 410 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 apps/cli/src/agents.rs diff --git a/apps/cli/src/agents.rs b/apps/cli/src/agents.rs new file mode 100644 index 00000000000..55d5a8ba6c2 --- /dev/null +++ b/apps/cli/src/agents.rs @@ -0,0 +1,410 @@ +use std::{ + io::IsTerminal, + path::{Path, PathBuf}, +}; + +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use toml_edit::{Array, DocumentMut, Item, Table, value}; + +use crate::{OutputFormat, atomic, resolve_format, write_json}; + +const BUNDLED_SKILL: &str = include_str!("../skill/cap/SKILL.md"); + +#[derive(Args)] +pub struct AgentsArgs { + #[command(subcommand)] + command: AgentsCommands, +} + +#[derive(Subcommand)] +enum AgentsCommands { + Install(InstallArgs), +} + +#[derive(Clone, Copy, ValueEnum)] +enum AgentTarget { + Codex, + Claude, + Cursor, +} + +#[derive(Clone, Copy, ValueEnum)] +enum AgentComponent { + Skill, + Mcp, + All, +} + +#[derive(Args)] +struct InstallArgs { + #[arg(long, value_enum)] + target: AgentTarget, + #[arg(long, value_enum)] + component: AgentComponent, + #[arg(long)] + dry_run: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlannedChange { + component: &'static str, + path: PathBuf, + action: &'static str, + value: Value, + #[serde(skip)] + expected: Option>, + #[serde(skip)] + content: Option>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstallResult { + target: &'static str, + dry_run: bool, + applied: bool, + changes: Vec, +} + +fn home_dir() -> Result { + dirs::home_dir().ok_or_else(|| "Could not locate the home directory".to_string()) +} + +fn target_name(target: AgentTarget) -> &'static str { + match target { + AgentTarget::Codex => "codex", + AgentTarget::Claude => "claude", + AgentTarget::Cursor => "cursor", + } +} + +fn skill_path(target: AgentTarget) -> Result { + let home = home_dir()?; + Ok(match target { + AgentTarget::Codex => std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")) + .join("skills/cap/SKILL.md"), + AgentTarget::Claude => home.join(".claude/skills/cap/SKILL.md"), + AgentTarget::Cursor => home.join(".cursor/skills/cap/SKILL.md"), + }) +} + +fn mcp_path(target: AgentTarget) -> Result { + let home = home_dir()?; + Ok(match target { + AgentTarget::Codex => std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")) + .join("config.toml"), + AgentTarget::Claude => home.join(".claude.json"), + AgentTarget::Cursor => home.join(".cursor/mcp.json"), + }) +} + +fn atomic_write(path: &Path, content: &[u8], private: bool) -> Result<(), String> { + #[cfg(not(unix))] + let _ = private; + let parent = path + .parent() + .ok_or_else(|| "Install path has no parent directory".to_string())?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + std::fs::write(&temporary, content).map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::metadata(path) + .map(|metadata| metadata.permissions()) + .unwrap_or_else(|_| { + std::fs::Permissions::from_mode(if private { 0o600 } else { 0o644 }) + }); + std::fs::set_permissions(&temporary, permissions).map_err(|error| error.to_string())?; + } + if let Err(error) = atomic::replace(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(error.to_string()); + } + Ok(()) +} + +fn read_optional(path: &Path) -> Result>, String> { + match std::fs::read(path) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +fn print_changes(changes: &[PlannedChange]) -> Result<(), String> { + for change in changes { + println!( + "{} {}: {}", + change.action, + change.component, + change.path.display() + ); + println!( + " {}", + serde_json::to_string(&change.value).map_err(|error| error.to_string())? + ); + } + Ok(()) +} + +fn mcp_value() -> Value { + json!({ "command": "cap", "args": ["mcp", "serve"] }) +} + +fn merge_json_mcp(existing: Option<&[u8]>) -> Result, String> { + let mut document = match existing { + Some(bytes) => serde_json::from_slice::(bytes).map_err(|error| error.to_string())?, + None => json!({}), + }; + let object = document + .as_object_mut() + .ok_or_else(|| "MCP configuration must be a JSON object".to_string())?; + let servers = object + .entry("mcpServers") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| "mcpServers must be a JSON object".to_string())?; + if let Some(current) = servers.get("cap") + && current != &mcp_value() + { + return Err("An incompatible MCP server named 'cap' already exists".to_string()); + } + servers.insert("cap".to_string(), mcp_value()); + serde_json::to_vec_pretty(&document).map_err(|error| error.to_string()) +} + +fn merge_codex_mcp(existing: Option<&str>) -> Result, String> { + let mut document = existing + .unwrap_or_default() + .parse::() + .map_err(|error| error.to_string())?; + if !document.as_table().contains_key("mcp_servers") { + document["mcp_servers"] = Item::Table(Table::new()); + } + let servers = document["mcp_servers"] + .as_table_mut() + .ok_or_else(|| "mcp_servers must be a TOML table".to_string())?; + if let Some(current) = servers.get("cap") { + let command = current.get("command").and_then(Item::as_str); + let args = current.get("args").and_then(Item::as_array); + let compatible = command == Some("cap") + && args.is_some_and(|args| { + args.len() == 2 + && args.get(0).and_then(toml_edit::Value::as_str) == Some("mcp") + && args.get(1).and_then(toml_edit::Value::as_str) == Some("serve") + }); + if !compatible { + return Err("An incompatible MCP server named 'cap' already exists".to_string()); + } + } + servers["cap"]["command"] = value("cap"); + let mut args = Array::new(); + args.push("mcp"); + args.push("serve"); + servers["cap"]["args"] = value(args); + Ok(document.to_string().into_bytes()) +} + +fn confirm() -> Result<(), String> { + if !std::io::stdin().is_terminal() { + return Err("Non-interactive installation requires --yes".to_string()); + } + eprint!("Apply these changes? [y/N] "); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|error| error.to_string())?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + return Err("Installation cancelled".to_string()); + } + Ok(()) +} + +impl InstallArgs { + fn includes_skill(&self) -> bool { + matches!(self.component, AgentComponent::Skill | AgentComponent::All) + } + + fn includes_mcp(&self) -> bool { + matches!(self.component, AgentComponent::Mcp | AgentComponent::All) + } + + fn plan(&self) -> Result, String> { + let mut changes = Vec::new(); + if self.includes_skill() { + let path = skill_path(self.target)?; + let expected = read_optional(&path)?; + let action = if expected.as_deref() == Some(BUNDLED_SKILL.as_bytes()) { + "unchanged" + } else if expected.is_some() { + "replace" + } else { + "create" + }; + let content = (action != "unchanged").then(|| BUNDLED_SKILL.as_bytes().to_vec()); + changes.push(PlannedChange { + component: "skill", + path, + action, + value: json!({ "name": "cap", "content": BUNDLED_SKILL }), + expected, + content, + }); + } + if self.includes_mcp() { + let path = mcp_path(self.target)?; + let expected = read_optional(&path)?; + let merged = if matches!(self.target, AgentTarget::Codex) { + let text = expected + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|error| error.to_string())?; + merge_codex_mcp(text)? + } else { + merge_json_mcp(expected.as_deref())? + }; + let unchanged = expected.as_deref() == Some(merged.as_slice()); + changes.push(PlannedChange { + component: "mcp", + action: if unchanged { + "unchanged" + } else if expected.is_some() { + "merge" + } else { + "create" + }, + path, + value: json!({ "name": "cap", "command": "cap", "args": ["mcp", "serve"] }), + expected, + content: (!unchanged).then_some(merged), + }); + } + Ok(changes) + } + + fn apply(&self, changes: &[PlannedChange]) -> Result<(), String> { + for change in changes { + if read_optional(&change.path)? != change.expected { + return Err(format!( + "{} changed after the installation preview; review a new preview before applying", + change.path.display() + )); + } + } + for change in changes { + if let Some(content) = &change.content { + atomic_write(&change.path, content, change.component == "mcp")?; + } + } + Ok(()) + } +} + +impl AgentsArgs { + pub fn run(self, global_json: bool) -> Result<(), String> { + let format = match &self.command { + AgentsCommands::Install(args) => resolve_format(global_json, args.format), + }; + let result = self.run_inner(global_json); + if let Err(error) = &result + && format == OutputFormat::Json + { + let _ = write_json(&json!({ "error": error })); + } + result + } + + fn run_inner(self, global_json: bool) -> Result<(), String> { + match self.command { + AgentsCommands::Install(args) => { + let format = resolve_format(global_json, args.format); + let changes = args.plan()?; + if args.dry_run { + let result = InstallResult { + target: target_name(args.target), + dry_run: true, + applied: false, + changes, + }; + return match format { + OutputFormat::Json => write_json(&result), + OutputFormat::Text => print_changes(&result.changes), + }; + } + if format == OutputFormat::Text { + print_changes(&changes)?; + } else if !args.yes { + eprintln!( + "{}", + serde_json::to_string_pretty(&changes).map_err(|error| error.to_string())? + ); + } + if !args.yes { + confirm()?; + } + args.apply(&changes)?; + let result = InstallResult { + target: target_name(args.target), + dry_run: false, + applied: true, + changes, + }; + match format { + OutputFormat::Json => write_json(&result), + OutputFormat::Text => { + println!("Installed Cap agent components for {}.", result.target); + Ok(()) + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_merge_preserves_unrelated_servers() { + let merged = merge_json_mcp(Some( + br#"{"mcpServers":{"other":{"command":"other"}},"setting":true}"#, + )) + .unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + assert_eq!(value["setting"], true); + assert_eq!(value["mcpServers"]["other"]["command"], "other"); + assert_eq!(value["mcpServers"]["cap"], mcp_value()); + } + + #[test] + fn codex_merge_rejects_conflicting_cap_server() { + let error = merge_codex_mcp(Some( + "[mcp_servers.cap]\ncommand = \"different\"\nargs = []\n", + )) + .unwrap_err(); + assert!(error.contains("incompatible")); + } + + #[test] + fn codex_merge_is_stable() { + let original = "[mcp_servers.cap]\ncommand = \"cap\"\nargs = [\"mcp\", \"serve\"]\n"; + assert_eq!( + merge_codex_mcp(Some(original)).unwrap(), + original.as_bytes() + ); + } +} From 8d0426af739194c64562ddc42ddc4b146cc07914 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 37/58] feat(cli): add analytics commands --- apps/cli/src/analytics.rs | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/cli/src/analytics.rs diff --git a/apps/cli/src/analytics.rs b/apps/cli/src/analytics.rs new file mode 100644 index 00000000000..0976f364718 --- /dev/null +++ b/apps/cli/src/analytics.rs @@ -0,0 +1,48 @@ +use clap::{Args, ValueEnum}; + +use crate::{OutputFormat, agent_client}; + +#[derive(Clone, Copy, ValueEnum)] +enum AnalyticsRange { + Day, + Week, + Month, + Year, +} + +impl AnalyticsRange { + const fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Week => "week", + Self::Month => "month", + Self::Year => "year", + } + } +} + +#[derive(Args)] +pub struct AnalyticsArgs { + #[arg(long)] + organization: String, + #[arg(long)] + space: Option, + #[arg(long)] + cap: Option, + #[arg(long, value_enum, default_value_t = AnalyticsRange::Month)] + range: AnalyticsRange, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +impl AnalyticsArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + let query = agent_client::query(&[ + ("organizationId", Some(self.organization)), + ("spaceId", self.space), + ("capId", self.cap), + ("range", Some(self.range.as_str().to_string())), + ]); + agent_client::read(&format!("/analytics?{query}"), global_json, self.format).await + } +} From 1e956ef7704ff34743f3284e95b1e5714ff66df7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 38/58] feat(cli): add notifications commands --- apps/cli/src/notifications.rs | 145 ++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 apps/cli/src/notifications.rs diff --git a/apps/cli/src/notifications.rs b/apps/cli/src/notifications.rs new file mode 100644 index 00000000000..52e5496fc09 --- /dev/null +++ b/apps/cli/src/notifications.rs @@ -0,0 +1,145 @@ +use clap::{ArgGroup, Args, Subcommand}; +use reqwest::Method; +use serde_json::{Map, Value, json}; + +use crate::{OutputFormat, agent_client, confirmation}; + +#[derive(Args)] +pub struct NotificationsArgs { + #[command(subcommand)] + command: NotificationCommands, +} + +#[derive(Subcommand)] +enum NotificationCommands { + List(NotificationListArgs), + Preferences(NotificationPreferencesArgs), + Read(NotificationReadArgs), +} + +#[derive(Args)] +struct NotificationListArgs { + #[arg(long)] + unread: bool, + #[arg(long)] + cursor: Option, + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u16).range(1..=100))] + limit: u16, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true)] +struct NotificationPreferencesArgs { + #[command(subcommand)] + command: Option, + #[arg(long)] + pause_comments: Option, + #[arg(long)] + pause_replies: Option, + #[arg(long)] + pause_views: Option, + #[arg(long)] + pause_reactions: Option, + #[arg(long)] + pause_anonymous_views: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Subcommand)] +enum NotificationPreferencesCommands { + Get(NotificationFormatArgs), +} + +#[derive(Args)] +struct NotificationFormatArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +#[command(group(ArgGroup::new("selection").required(true).args(["all", "ids"])))] +struct NotificationReadArgs { + #[arg(long)] + all: bool, + #[arg(long, value_delimiter = ',')] + ids: Vec, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +impl NotificationsArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + match self.command { + NotificationCommands::List(args) => { + let query = agent_client::query(&[ + ("unread", args.unread.then(|| "true".to_string())), + ("cursor", args.cursor), + ("limit", Some(args.limit.to_string())), + ]); + agent_client::read( + &format!("/me/notifications?{query}"), + global_json, + args.format, + ) + .await + } + NotificationCommands::Preferences(args) => { + if let Some(NotificationPreferencesCommands::Get(format)) = args.command { + return agent_client::read( + "/me/notification-preferences", + global_json, + format.format, + ) + .await; + } + let mut body = Map::new(); + for (key, value) in [ + ("pauseComments", args.pause_comments), + ("pauseReplies", args.pause_replies), + ("pauseViews", args.pause_views), + ("pauseReactions", args.pause_reactions), + ("pauseAnonymousViews", args.pause_anonymous_views), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + if body.is_empty() { + return agent_client::read( + "/me/notification-preferences", + global_json, + args.format, + ) + .await; + } + confirmation::require(args.yes, "Update notification preferences")?; + agent_client::mutate( + Method::PATCH, + "/me/notification-preferences", + &json!(body), + global_json, + args.format, + ) + .await + } + NotificationCommands::Read(args) => { + confirmation::require(args.yes, "Mark notifications as read")?; + agent_client::mutate( + Method::POST, + "/me/notifications/read", + &json!({ "all": args.all, "ids": args.ids }), + global_json, + args.format, + ) + .await + } + } + } +} From 4a7d1f227af7a2f60323c51c3767f688af8e3053 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 39/58] feat(cli): add organizations commands --- apps/cli/src/organizations.rs | 1085 +++++++++++++++++++++++++++++++++ 1 file changed, 1085 insertions(+) create mode 100644 apps/cli/src/organizations.rs diff --git a/apps/cli/src/organizations.rs b/apps/cli/src/organizations.rs new file mode 100644 index 00000000000..741a04807f3 --- /dev/null +++ b/apps/cli/src/organizations.rs @@ -0,0 +1,1085 @@ +use std::{ + io::{self, IsTerminal, Read}, + path::PathBuf, +}; + +use clap::{Args, Subcommand, ValueEnum}; +use reqwest::Method; +use serde_json::{Map, Value, json}; + +use crate::{ + OutputFormat, agent_client, + caps::{AgentClient, opaque_id}, + confirmation, resolve_format, +}; + +#[derive(Args)] +pub struct OrganizationsArgs { + #[command(subcommand)] + command: OrganizationCommands, +} + +#[derive(Subcommand)] +enum OrganizationCommands { + List(FormatArgs), + Create(OrganizationCreateArgs), + Get(OrganizationArgs), + Members(OrganizationArgs), + Invites(OrganizationArgs), + Billing(OrganizationBillingArgs), + Storage(OrganizationStorageArgs), + Update(OrganizationUpdateArgs), + Icon(OrganizationIconArgs), + ShareableIcon(OrganizationIconArgs), + Settings(OrganizationSettingsArgs), + Invite(OrganizationInviteArgs), + Member(OrganizationMemberArgs), + Domain(OrganizationDomainArgs), + Delete(OrganizationDeleteArgs), +} + +#[derive(Args)] +struct FormatArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationArgs { + organization: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationCreateArgs { + name: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationIconArgs { + #[command(subcommand)] + command: OrganizationIconCommands, +} + +#[derive(Subcommand)] +enum OrganizationIconCommands { + Set(OrganizationIconSetArgs), + Remove(OrganizationIconRemoveArgs), +} + +#[derive(Args)] +struct OrganizationIconSetArgs { + organization: String, + image: PathBuf, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationIconRemoveArgs { + organization: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationBillingArgs { + #[command(subcommand)] + command: OrganizationBillingCommands, +} + +#[derive(Subcommand)] +enum OrganizationBillingCommands { + Get(OrganizationArgs), + Checkout(OrganizationBillingCheckoutArgs), + Portal(OrganizationBillingPortalArgs), +} + +#[derive(Clone, Copy, ValueEnum)] +enum BillingInterval { + Monthly, + Yearly, +} + +impl BillingInterval { + const fn as_str(self) -> &'static str { + match self { + Self::Monthly => "monthly", + Self::Yearly => "yearly", + } + } +} + +#[derive(Args)] +struct OrganizationBillingCheckoutArgs { + organization: String, + #[arg(long, value_enum, default_value_t = BillingInterval::Yearly)] + interval: BillingInterval, + #[arg(long)] + quantity: Option, + #[arg(long)] + no_open: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationBillingPortalArgs { + organization: String, + #[arg(long)] + no_open: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationStorageArgs { + #[command(subcommand)] + command: OrganizationStorageCommands, +} + +#[derive(Subcommand)] +enum OrganizationStorageCommands { + List(OrganizationArgs), + S3(OrganizationS3Args), + Provider(OrganizationStorageProviderArgs), + GoogleDrive(OrganizationGoogleDriveArgs), +} + +#[derive(Args)] +struct OrganizationS3Args { + #[command(subcommand)] + command: OrganizationS3Commands, +} + +#[derive(Subcommand)] +enum OrganizationS3Commands { + Set(OrganizationS3ConfigArgs), + Test(OrganizationS3ConfigArgs), + Remove(OrganizationS3RemoveArgs), +} + +#[derive(Args)] +struct OrganizationS3ConfigArgs { + organization: String, + #[arg(long, default_value = "aws")] + provider: String, + #[arg(long, default_value = "https://s3.amazonaws.com")] + endpoint: String, + #[arg(long)] + bucket: String, + #[arg(long, default_value = "us-east-1")] + region: String, + #[arg(long, conflicts_with = "credentials_stdin")] + reuse_credentials: bool, + #[arg(long, conflicts_with = "reuse_credentials")] + credentials_stdin: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationS3RemoveArgs { + organization: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum OrganizationStorageProvider { + S3, + GoogleDrive, +} + +impl OrganizationStorageProvider { + const fn as_str(self) -> &'static str { + match self { + Self::S3 => "s3", + Self::GoogleDrive => "googleDrive", + } + } +} + +#[derive(Args)] +struct OrganizationStorageProviderArgs { + organization: String, + #[arg(long, value_enum)] + provider: OrganizationStorageProvider, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationGoogleDriveArgs { + #[command(subcommand)] + command: OrganizationGoogleDriveCommands, +} + +#[derive(Subcommand)] +enum OrganizationGoogleDriveCommands { + Connect(OrganizationGoogleDriveConnectArgs), + Folders(OrganizationGoogleDriveFoldersArgs), + Location(OrganizationGoogleDriveLocationArgs), + Disconnect(OrganizationGoogleDriveDisconnectArgs), +} + +#[derive(Args)] +struct OrganizationGoogleDriveConnectArgs { + organization: String, + #[arg(long)] + no_open: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationGoogleDriveFoldersArgs { + organization: String, + #[arg(long)] + parent_id: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationGoogleDriveLocationArgs { + organization: String, + folder_id: String, + #[arg(long)] + folder_name: Option, + #[arg(long)] + drive_id: Option, + #[arg(long)] + drive_name: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationGoogleDriveDisconnectArgs { + organization: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum OrganizationRole { + Admin, + Member, +} + +impl OrganizationRole { + const fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Member => "member", + } + } +} + +#[derive(Args)] +struct OrganizationUpdateArgs { + organization: String, + #[arg(long)] + name: Option, + #[arg(long, conflicts_with = "clear_allowed_email_domain")] + allowed_email_domain: Option, + #[arg(long)] + clear_allowed_email_domain: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationSettingsArgs { + organization: String, + #[arg(long)] + disable_summary: Option, + #[arg(long)] + disable_captions: Option, + #[arg(long)] + disable_chapters: Option, + #[arg(long)] + disable_reactions: Option, + #[arg(long)] + disable_transcript: Option, + #[arg(long)] + disable_comments: Option, + #[arg(long)] + hide_shareable_link_cap_logo: Option, + #[arg(long)] + shareable_link_use_organization_icon: Option, + #[arg(long)] + ai_generation_language: Option, + #[arg(long)] + default_playback_speed: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationInviteArgs { + #[command(subcommand)] + command: OrganizationInviteCommands, +} + +#[derive(Subcommand)] +enum OrganizationInviteCommands { + Add(OrganizationInviteAddArgs), + Remove(OrganizationInviteRemoveArgs), +} + +#[derive(Args)] +struct OrganizationInviteAddArgs { + organization: String, + #[arg(long)] + email: String, + #[arg(long, value_enum, default_value_t = OrganizationRole::Member)] + role: OrganizationRole, + #[arg(long, help = "Create a link without delivering the invitation email")] + no_email: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationInviteRemoveArgs { + organization: String, + invite_id: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationMemberArgs { + #[command(subcommand)] + command: OrganizationMemberCommands, +} + +#[derive(Subcommand)] +enum OrganizationMemberCommands { + Role(OrganizationMemberRoleArgs), + Seat(OrganizationMemberSeatArgs), + Remove(OrganizationMemberRemoveArgs), +} + +#[derive(Args)] +struct OrganizationMemberRoleArgs { + organization: String, + member_id: String, + #[arg(long, value_enum)] + role: OrganizationRole, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationMemberRemoveArgs { + organization: String, + member_id: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationMemberSeatArgs { + organization: String, + member_id: String, + #[arg(long, conflicts_with = "disable")] + enable: bool, + #[arg(long, conflicts_with = "enable")] + disable: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationDeleteArgs { + organization: String, + #[arg(long)] + wait: bool, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationDomainArgs { + #[command(subcommand)] + command: OrganizationDomainCommands, +} + +#[derive(Subcommand)] +enum OrganizationDomainCommands { + Set(OrganizationDomainSetArgs), + Remove(OrganizationDomainOperationArgs), + Verify(OrganizationDomainOperationArgs), +} + +#[derive(Args)] +struct OrganizationDomainSetArgs { + organization: String, + domain: String, + #[arg(long)] + wait: bool, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct OrganizationDomainOperationArgs { + organization: String, + #[arg(long)] + wait: bool, + #[arg(long, default_value_t = 600)] + timeout: u64, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +async fn run_operation( + method: Method, + path: &str, + wait: bool, + timeout: u64, + format: OutputFormat, + global_json: bool, +) -> Result<(), String> { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let mut value = client + .mutate_json_confirmed(method, path, &json!({})) + .await + .map_err(|error| error.to_string())?; + if wait { + let operation_id = value + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| "Cap returned an invalid operation".to_string())?; + value = crate::jobs::wait_operation(&client, operation_id, timeout) + .await + .map_err(|error| error.to_string())?; + } + agent_client::print_value(&value, resolve_format(global_json, format)) +} + +fn read_s3_credentials(reuse: bool, credentials_stdin: bool) -> Result<(String, String), String> { + if reuse { + return Ok((String::new(), String::new())); + } + if credentials_stdin { + let mut value = String::new(); + io::stdin() + .lock() + .take(16 * 1024) + .read_to_string(&mut value) + .map_err(|error| error.to_string())?; + let lines = value.lines().collect::>(); + if lines.len() != 2 || lines.iter().any(|line| line.is_empty()) { + return Err( + "--credentials-stdin expects the access key ID and secret access key on exactly two non-empty lines" + .to_string(), + ); + } + return Ok((lines[0].to_string(), lines[1].to_string())); + } + if !io::stdin().is_terminal() { + return Err( + "Non-interactive S3 configuration requires --credentials-stdin or --reuse-credentials" + .to_string(), + ); + } + let access_key_id = + rpassword::prompt_password("S3 access key ID: ").map_err(|error| error.to_string())?; + let secret_access_key = + rpassword::prompt_password("S3 secret access key: ").map_err(|error| error.to_string())?; + if access_key_id.is_empty() || secret_access_key.is_empty() { + return Err("S3 credentials cannot be empty".to_string()); + } + Ok((access_key_id, secret_access_key)) +} + +fn s3_config_body(args: &OrganizationS3ConfigArgs) -> Result { + let (access_key_id, secret_access_key) = + read_s3_credentials(args.reuse_credentials, args.credentials_stdin)?; + Ok(json!({ + "provider": args.provider, + "accessKeyId": access_key_id, + "secretAccessKey": secret_access_key, + "endpoint": args.endpoint, + "bucketName": args.bucket, + "region": args.region, + })) +} + +impl OrganizationsArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + match self.command { + OrganizationCommands::List(args) => { + agent_client::read("/organizations", global_json, args.format).await + } + OrganizationCommands::Create(args) => { + confirmation::require(args.yes, "Create the organization")?; + agent_client::mutate_confirmed( + Method::POST, + "/organizations", + &json!({ "name": args.name }), + global_json, + args.format, + ) + .await + } + OrganizationCommands::Get(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read(&format!("/organizations/{id}"), global_json, args.format).await + } + OrganizationCommands::Members(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read( + &format!("/organizations/{id}/members"), + global_json, + args.format, + ) + .await + } + OrganizationCommands::Invites(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read( + &format!("/organizations/{id}/invites"), + global_json, + args.format, + ) + .await + } + OrganizationCommands::Billing(args) => match args.command { + OrganizationBillingCommands::Get(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read( + &format!("/organizations/{id}/billing"), + global_json, + args.format, + ) + .await + } + OrganizationBillingCommands::Checkout(args) => { + confirmation::require(args.yes, "Create the Cap Pro checkout")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let client = + AgentClient::from_credentials().map_err(|error| error.to_string())?; + let mut body = Map::from_iter([( + "interval".to_string(), + Value::String(args.interval.as_str().to_string()), + )]); + if let Some(quantity) = args.quantity { + body.insert("quantity".to_string(), Value::from(quantity)); + } + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/billing/checkout"), + &Value::Object(body), + ) + .await + .map_err(|error| error.to_string())?; + agent_client::open_browser_action(&value, args.no_open); + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + OrganizationBillingCommands::Portal(args) => { + confirmation::require(args.yes, "Open the Cap billing portal")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let client = + AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/billing/portal"), + &json!({}), + ) + .await + .map_err(|error| error.to_string())?; + agent_client::open_browser_action(&value, args.no_open); + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + }, + OrganizationCommands::Storage(args) => match args.command { + OrganizationStorageCommands::List(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::read( + &format!("/organizations/{id}/storage-integrations"), + global_json, + args.format, + ) + .await + } + OrganizationStorageCommands::S3(args) => match args.command { + OrganizationS3Commands::Set(args) => { + confirmation::require(args.yes, "Configure organization S3 storage")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let body = s3_config_body(&args)?; + agent_client::mutate_confirmed( + Method::PUT, + &format!("/organizations/{id}/storage/s3"), + &body, + global_json, + args.format, + ) + .await + } + OrganizationS3Commands::Test(args) => { + confirmation::require(args.yes, "Test organization S3 storage")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let body = s3_config_body(&args)?; + agent_client::mutate_confirmed( + Method::POST, + &format!("/organizations/{id}/storage/s3/test"), + &body, + global_json, + args.format, + ) + .await + } + OrganizationS3Commands::Remove(args) => { + confirmation::require(args.yes, "Remove organization S3 storage")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/storage/s3"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + OrganizationStorageCommands::Provider(args) => { + confirmation::require(args.yes, "Change the organization storage provider")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/organizations/{id}/storage/provider"), + &json!({ "provider": args.provider.as_str() }), + global_json, + args.format, + ) + .await + } + OrganizationStorageCommands::GoogleDrive(args) => match args.command { + OrganizationGoogleDriveCommands::Connect(args) => { + confirmation::require(args.yes, "Connect organization Google Drive")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let client = + AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/storage/google-drive/connect"), + &json!({}), + ) + .await + .map_err(|error| error.to_string())?; + agent_client::open_browser_action(&value, args.no_open); + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + OrganizationGoogleDriveCommands::Folders(args) => { + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let query = agent_client::query(&[("parentId", args.parent_id)]); + let path = if query.is_empty() { + format!("/organizations/{id}/storage/google-drive/folders") + } else { + format!("/organizations/{id}/storage/google-drive/folders?{query}") + }; + agent_client::read(&path, global_json, args.format).await + } + OrganizationGoogleDriveCommands::Location(args) => { + confirmation::require( + args.yes, + "Change the organization Google Drive location", + )?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::PUT, + &format!("/organizations/{id}/storage/google-drive/location"), + &json!({ + "folderId": args.folder_id, + "folderName": args.folder_name, + "driveId": args.drive_id, + "driveName": args.drive_name, + }), + global_json, + args.format, + ) + .await + } + OrganizationGoogleDriveCommands::Disconnect(args) => { + confirmation::require(args.yes, "Disconnect organization Google Drive")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/storage/google-drive"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + }, + OrganizationCommands::Update(args) => { + confirmation::require(args.yes, "Update the organization")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let mut body = Map::new(); + if let Some(name) = args.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(domain) = args.allowed_email_domain { + body.insert("allowedEmailDomain".to_string(), Value::String(domain)); + } else if args.clear_allowed_email_domain { + body.insert("allowedEmailDomain".to_string(), Value::Null); + } + if body.is_empty() { + return Err( + "Provide --name, --allowed-email-domain, or --clear-allowed-email-domain" + .to_string(), + ); + } + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/organizations/{id}"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + OrganizationCommands::Icon(args) => match args.command { + OrganizationIconCommands::Set(args) => { + confirmation::require(args.yes, "Update the organization icon")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let body = agent_client::image_payload(&args.image)?; + agent_client::mutate_confirmed( + Method::PUT, + &format!("/organizations/{id}/icon"), + &body, + global_json, + args.format, + ) + .await + } + OrganizationIconCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the organization icon")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/icon"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + OrganizationCommands::ShareableIcon(args) => match args.command { + OrganizationIconCommands::Set(args) => { + confirmation::require(args.yes, "Update the shareable link icon")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let body = agent_client::image_payload(&args.image)?; + agent_client::mutate_confirmed( + Method::PUT, + &format!("/organizations/{id}/shareable-link-icon"), + &body, + global_json, + args.format, + ) + .await + } + OrganizationIconCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the shareable link icon")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/shareable-link-icon"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + OrganizationCommands::Settings(args) => { + confirmation::require(args.yes, "Update organization preferences")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let mut body = Map::new(); + for (key, value) in [ + ("disableSummary", args.disable_summary), + ("disableCaptions", args.disable_captions), + ("disableChapters", args.disable_chapters), + ("disableReactions", args.disable_reactions), + ("disableTranscript", args.disable_transcript), + ("disableComments", args.disable_comments), + ( + "hideShareableLinkCapLogo", + args.hide_shareable_link_cap_logo, + ), + ( + "shareableLinkUseOrganizationIcon", + args.shareable_link_use_organization_icon, + ), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + if let Some(language) = args.ai_generation_language { + body.insert("aiGenerationLanguage".to_string(), Value::String(language)); + } + if let Some(speed) = args.default_playback_speed { + let speed = serde_json::Number::from_f64(speed) + .ok_or_else(|| "Default playback speed must be finite".to_string())?; + body.insert("defaultPlaybackSpeed".to_string(), Value::Number(speed)); + } + if body.is_empty() { + return Err("Provide at least one organization preference".to_string()); + } + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/organizations/{id}/settings"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + OrganizationCommands::Invite(args) => match args.command { + OrganizationInviteCommands::Add(args) => { + confirmation::require( + args.yes, + if args.no_email { + "Create the organization invite link" + } else { + "Create and email the organization invite" + }, + )?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::POST, + &format!("/organizations/{id}/invites"), + &json!({ + "email": args.email, + "role": args.role.as_str(), + "sendEmail": !args.no_email, + }), + global_json, + args.format, + ) + .await + } + OrganizationInviteCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the organization invite")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let invite_id = opaque_id(&args.invite_id, "Invite ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/invites/{invite_id}"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + OrganizationCommands::Member(args) => match args.command { + OrganizationMemberCommands::Role(args) => { + confirmation::require(args.yes, "Change the organization member role")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let member_id = opaque_id(&args.member_id, "Member ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/organizations/{id}/members/{member_id}"), + &json!({ "role": args.role.as_str() }), + global_json, + args.format, + ) + .await + } + OrganizationMemberCommands::Seat(args) => { + confirmation::require(args.yes, "Change the organization member Pro seat")?; + if args.enable == args.disable { + return Err("Provide exactly one of --enable or --disable".to_string()); + } + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let member_id = opaque_id(&args.member_id, "Member ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/organizations/{id}/members/{member_id}/seat"), + &json!({ "enabled": args.enable }), + global_json, + args.format, + ) + .await + } + OrganizationMemberCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the organization member")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let member_id = opaque_id(&args.member_id, "Member ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/organizations/{id}/members/{member_id}"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + OrganizationCommands::Delete(args) => { + confirmation::require( + args.yes, + "Permanently delete the organization and all of its Caps", + )?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + run_operation( + Method::DELETE, + &format!("/organizations/{id}"), + args.wait, + args.timeout, + args.format, + global_json, + ) + .await + } + OrganizationCommands::Domain(args) => match args.command { + OrganizationDomainCommands::Set(args) => { + confirmation::require(args.yes, "Set the organization custom domain")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + let client = + AgentClient::from_credentials().map_err(|error| error.to_string())?; + let mut value = client + .mutate_json_confirmed( + Method::PUT, + &format!("/organizations/{id}/domain"), + &json!({ "domain": args.domain }), + ) + .await + .map_err(|error| error.to_string())?; + if args.wait { + let operation_id = value + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| "Cap returned an invalid operation".to_string())?; + value = crate::jobs::wait_operation(&client, operation_id, args.timeout) + .await + .map_err(|error| error.to_string())?; + } + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + OrganizationDomainCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the organization custom domain")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + run_operation( + Method::DELETE, + &format!("/organizations/{id}/domain"), + args.wait, + args.timeout, + args.format, + global_json, + ) + .await + } + OrganizationDomainCommands::Verify(args) => { + confirmation::require(args.yes, "Verify the organization custom domain")?; + let id = opaque_id(&args.organization, "Organization ID") + .map_err(|error| error.to_string())?; + run_operation( + Method::POST, + &format!("/organizations/{id}/domain/verify"), + args.wait, + args.timeout, + args.format, + global_json, + ) + .await + } + }, + } + } +} From ef35647fe2fc8056781eebf3509598a8748ef272 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 40/58] feat(cli): add developers commands --- apps/cli/src/developers.rs | 460 +++++++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 apps/cli/src/developers.rs diff --git a/apps/cli/src/developers.rs b/apps/cli/src/developers.rs new file mode 100644 index 00000000000..a831b03ba91 --- /dev/null +++ b/apps/cli/src/developers.rs @@ -0,0 +1,460 @@ +use clap::{Args, Subcommand, ValueEnum}; +use reqwest::Method; +use serde_json::{Map, Value, json}; + +use crate::{ + OutputFormat, agent_client, + caps::{AgentClient, opaque_id}, + confirmation, resolve_format, +}; + +#[derive(Args)] +pub struct DevelopersArgs { + #[command(subcommand)] + command: DeveloperCommands, +} + +#[derive(Subcommand)] +enum DeveloperCommands { + List(FormatArgs), + Get(DeveloperGetArgs), + Create(DeveloperCreateArgs), + Update(DeveloperUpdateArgs), + Delete(DeveloperDeleteArgs), + Domains(DeveloperDomainsArgs), + Keys(DeveloperKeysArgs), + AutoTopUp(DeveloperAutoTopUpArgs), + Credits(DeveloperCreditsArgs), + Videos(DeveloperVideosArgs), + Transactions(DeveloperTransactionsArgs), +} + +#[derive(Args)] +struct FormatArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperGetArgs { + app: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +enum DeveloperEnvironment { + Development, + Production, +} + +impl DeveloperEnvironment { + const fn as_str(self) -> &'static str { + match self { + Self::Development => "development", + Self::Production => "production", + } + } +} + +#[derive(Args)] +struct DeveloperCreateArgs { + name: String, + #[arg(long, value_enum, default_value_t = DeveloperEnvironment::Development)] + environment: DeveloperEnvironment, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperUpdateArgs { + app: String, + #[arg(long)] + name: Option, + #[arg(long, value_enum)] + environment: Option, + #[arg(long)] + logo_url: Option, + #[arg(long)] + clear_logo: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperDeleteArgs { + app: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperDomainsArgs { + #[command(subcommand)] + command: DeveloperDomainCommands, +} + +#[derive(Subcommand)] +enum DeveloperDomainCommands { + Add(DeveloperDomainAddArgs), + Remove(DeveloperDomainRemoveArgs), +} + +#[derive(Args)] +struct DeveloperDomainAddArgs { + app: String, + domain: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperDomainRemoveArgs { + app: String, + domain_id: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperKeysArgs { + #[command(subcommand)] + command: DeveloperKeyCommands, +} + +#[derive(Subcommand)] +enum DeveloperKeyCommands { + Rotate(DeveloperKeyRotateArgs), +} + +#[derive(Args)] +struct DeveloperKeyRotateArgs { + app: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperAutoTopUpArgs { + app: String, + #[arg(long, conflicts_with = "disable")] + enable: bool, + #[arg(long, conflicts_with = "enable")] + disable: bool, + #[arg(long)] + threshold_micro_credits: Option, + #[arg(long)] + amount_cents: Option, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperCreditsArgs { + #[command(subcommand)] + command: DeveloperCreditCommands, +} + +#[derive(Subcommand)] +enum DeveloperCreditCommands { + Purchase(DeveloperCreditPurchaseArgs), +} + +#[derive(Args)] +struct DeveloperCreditPurchaseArgs { + app: String, + #[arg(long)] + amount_cents: u32, + #[arg(long)] + no_open: bool, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperVideosArgs { + #[command(subcommand)] + command: DeveloperVideoCommands, +} + +#[derive(Subcommand)] +enum DeveloperVideoCommands { + List(DeveloperVideoListArgs), + Delete(DeveloperVideoDeleteArgs), +} + +#[derive(Args)] +struct DeveloperVideoListArgs { + app: String, + #[arg(long)] + user_id: Option, + #[arg(long)] + cursor: Option, + #[arg(long, default_value_t = 50)] + limit: u16, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperVideoDeleteArgs { + app: String, + video: String, + #[arg(long)] + yes: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DeveloperTransactionsArgs { + app: String, + #[arg(long)] + cursor: Option, + #[arg(long, default_value_t = 50)] + limit: u16, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +impl DevelopersArgs { + pub async fn run(self, global_json: bool) -> Result<(), String> { + match self.command { + DeveloperCommands::List(args) => { + agent_client::read("/developer/apps", global_json, args.format).await + } + DeveloperCommands::Get(args) => { + let id = + opaque_id(&args.app, "Developer app ID").map_err(|error| error.to_string())?; + agent_client::read( + &format!("/developer/apps/{id}/context"), + global_json, + args.format, + ) + .await + } + DeveloperCommands::Create(args) => { + confirmation::require( + args.yes, + "Create the developer app and reveal its API credentials", + )?; + agent_client::mutate_confirmed( + Method::POST, + "/developer/apps", + &json!({ + "name": args.name, + "environment": args.environment.as_str(), + }), + global_json, + args.format, + ) + .await + } + DeveloperCommands::Update(args) => { + confirmation::require(args.yes, "Update the developer app")?; + let id = + opaque_id(&args.app, "Developer app ID").map_err(|error| error.to_string())?; + if args.logo_url.is_some() && args.clear_logo { + return Err("--logo-url and --clear-logo cannot be combined".to_string()); + } + let mut body = Map::new(); + if let Some(name) = args.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(environment) = args.environment { + body.insert( + "environment".to_string(), + Value::String(environment.as_str().to_string()), + ); + } + if let Some(logo_url) = args.logo_url { + body.insert("logoUrl".to_string(), Value::String(logo_url)); + } else if args.clear_logo { + body.insert("logoUrl".to_string(), Value::Null); + } + if body.is_empty() { + return Err("Provide at least one developer app update".to_string()); + } + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/developer/apps/{id}"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + DeveloperCommands::Delete(args) => { + confirmation::require(args.yes, "Delete the developer app and revoke its keys")?; + let id = + opaque_id(&args.app, "Developer app ID").map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/developer/apps/{id}"), + &json!({}), + global_json, + args.format, + ) + .await + } + DeveloperCommands::Domains(args) => match args.command { + DeveloperDomainCommands::Add(args) => { + confirmation::require(args.yes, "Add the developer app domain")?; + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::POST, + &format!("/developer/apps/{id}/domains"), + &json!({ "domain": args.domain }), + global_json, + args.format, + ) + .await + } + DeveloperDomainCommands::Remove(args) => { + confirmation::require(args.yes, "Remove the developer app domain")?; + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + let domain_id = opaque_id(&args.domain_id, "Developer domain ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/developer/apps/{id}/domains/{domain_id}"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + DeveloperCommands::Keys(args) => match args.command { + DeveloperKeyCommands::Rotate(args) => { + confirmation::require( + args.yes, + "Rotate and reveal the developer app API credentials", + )?; + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::POST, + &format!("/developer/apps/{id}/keys/rotate"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + DeveloperCommands::AutoTopUp(args) => { + confirmation::require(args.yes, "Change automatic developer credit purchases")?; + if args.enable == args.disable { + return Err("Provide exactly one of --enable or --disable".to_string()); + } + let id = + opaque_id(&args.app, "Developer app ID").map_err(|error| error.to_string())?; + let mut body = Map::from_iter([("enabled".to_string(), Value::Bool(args.enable))]); + if let Some(value) = args.threshold_micro_credits { + body.insert("thresholdMicroCredits".to_string(), Value::from(value)); + } + if let Some(value) = args.amount_cents { + body.insert("amountCents".to_string(), Value::from(value)); + } + agent_client::mutate_confirmed( + Method::PATCH, + &format!("/developer/apps/{id}/auto-top-up"), + &Value::Object(body), + global_json, + args.format, + ) + .await + } + DeveloperCommands::Credits(args) => match args.command { + DeveloperCreditCommands::Purchase(args) => { + confirmation::require(args.yes, "Create a developer credit purchase checkout")?; + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + let client = + AgentClient::from_credentials().map_err(|error| error.to_string())?; + let value = client + .mutate_json_confirmed( + Method::POST, + &format!("/developer/apps/{id}/credits/checkout"), + &json!({ "amountCents": args.amount_cents }), + ) + .await + .map_err(|error| error.to_string())?; + agent_client::open_browser_action(&value, args.no_open); + agent_client::print_value(&value, resolve_format(global_json, args.format)) + } + }, + DeveloperCommands::Videos(args) => match args.command { + DeveloperVideoCommands::List(args) => { + if !(1..=100).contains(&args.limit) { + return Err("--limit must be between 1 and 100".to_string()); + } + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + let query = agent_client::query(&[ + ("userId", args.user_id), + ("cursor", args.cursor), + ("limit", Some(args.limit.to_string())), + ]); + agent_client::read( + &format!("/developer/apps/{id}/videos?{query}"), + global_json, + args.format, + ) + .await + } + DeveloperVideoCommands::Delete(args) => { + confirmation::require(args.yes, "Delete the developer SDK video")?; + let id = opaque_id(&args.app, "Developer app ID") + .map_err(|error| error.to_string())?; + let video_id = opaque_id(&args.video, "Developer video ID") + .map_err(|error| error.to_string())?; + agent_client::mutate_confirmed( + Method::DELETE, + &format!("/developer/apps/{id}/videos/{video_id}"), + &json!({}), + global_json, + args.format, + ) + .await + } + }, + DeveloperCommands::Transactions(args) => { + if !(1..=100).contains(&args.limit) { + return Err("--limit must be between 1 and 100".to_string()); + } + let id = + opaque_id(&args.app, "Developer app ID").map_err(|error| error.to_string())?; + let query = agent_client::query(&[ + ("cursor", args.cursor), + ("limit", Some(args.limit.to_string())), + ]); + agent_client::read( + &format!("/developer/apps/{id}/transactions?{query}"), + global_json, + args.format, + ) + .await + } + } + } +} From 03528dee53bfbe33edb06b95e641c5379f1e57d4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 41/58] feat(cli): add MCP server integration --- apps/cli/src/mcp.rs | 3569 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3569 insertions(+) create mode 100644 apps/cli/src/mcp.rs diff --git a/apps/cli/src/mcp.rs b/apps/cli/src/mcp.rs new file mode 100644 index 00000000000..2a85ce41e0a --- /dev/null +++ b/apps/cli/src/mcp.rs @@ -0,0 +1,3569 @@ +use std::time::Duration; + +use clap::{Args, Subcommand}; +use reqwest::Method; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + CallToolResult, ContentBlock, Implementation, ListResourceTemplatesResult, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ResourceContents, ResourceTemplate, ServerCapabilities, ServerInfo, + }, + schemars, tool, tool_handler, tool_router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::caps::{AgentApiError, AgentClient, cap_id, opaque_id}; + +#[derive(Args)] +pub struct McpArgs { + #[command(subcommand)] + command: McpCommands, +} + +#[derive(Subcommand)] +enum McpCommands { + Serve, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ListCapsInput { + #[serde(default)] + scope: Option, + #[serde(default)] + organization_id: Option, + #[serde(default)] + folder_id: Option, + #[serde(default)] + search: Option, + #[serde(default)] + updated_after: Option, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +fn list_caps_path(input: ListCapsInput) -> Result { + let scope = input.scope.unwrap_or_else(|| "all".to_string()); + if !matches!(scope.as_str(), "all" | "owned" | "shared") { + return Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "scope must be all, owned, or shared".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }); + } + let limit = input.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "limit must be between 1 and 100".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }); + } + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("scope", &scope); + query.append_pair("limit", &limit.to_string()); + for (key, value) in [ + ("organizationId", input.organization_id), + ("folderId", input.folder_id), + ("search", input.search), + ("updatedAfter", input.updated_after), + ("cursor", input.cursor), + ] { + if let Some(value) = value { + query.append_pair(key, &value); + } + } + Ok(format!("/caps?{}", query.finish())) +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CapInput { + cap: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct WaitInput { + cap: String, + #[serde(default)] + wait_for: Option, + #[serde(default)] + timeout_seconds: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ProcessInput { + cap: String, + #[serde(default)] + target: Option, + #[serde(default)] + retry: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct LoomImportInput { + loom_url: String, + organization_id: String, + #[serde(default)] + owner_email: Option, + #[serde(default)] + space_name: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +struct TranscriptCueInput { + start_ms: u64, + end_ms: u64, + text: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TranscriptReplaceInput { + cap: String, + expected_revision: String, + cues: Vec, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CapOperationInput { + cap: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OperationInput { + operation_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OperationWaitInput { + operation_id: String, + #[serde(default)] + timeout_seconds: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct FeedbackInput { + cap: String, + content: String, + #[serde(default)] + timestamp_ms: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ReplyInput { + cap: String, + comment_id: String, + content: String, + #[serde(default)] + timestamp_ms: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TitleInput { + cap: String, + title: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct VisibilityInput { + cap: String, + public: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationInput { + organization_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationCreateInput { + name: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationActionInput { + organization_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationBillingCheckoutInput { + organization_id: String, + #[serde(default)] + interval: Option, + #[serde(default)] + quantity: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationStorageProviderInput { + organization_id: String, + provider: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationGoogleDriveFoldersInput { + organization_id: String, + #[serde(default)] + parent_id: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationGoogleDriveLocationInput { + organization_id: String, + folder_id: String, + #[serde(default)] + folder_name: Option, + #[serde(default)] + drive_id: Option, + #[serde(default)] + drive_name: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationUpdateInput { + organization_id: String, + #[serde(default)] + name: Option, + #[serde(default)] + allowed_email_domain: Option, + #[serde(default)] + clear_allowed_email_domain: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationSettingsInput { + organization_id: String, + #[serde(default)] + disable_summary: Option, + #[serde(default)] + disable_captions: Option, + #[serde(default)] + disable_chapters: Option, + #[serde(default)] + disable_reactions: Option, + #[serde(default)] + disable_transcript: Option, + #[serde(default)] + disable_comments: Option, + #[serde(default)] + hide_shareable_link_cap_logo: Option, + #[serde(default)] + shareable_link_use_organization_icon: Option, + #[serde(default)] + ai_generation_language: Option, + #[serde(default)] + default_playback_speed: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationInviteAddInput { + organization_id: String, + email: String, + #[serde(default)] + role: Option, + #[serde(default)] + send_email: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationInviteRemoveInput { + organization_id: String, + invite_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationMemberRoleInput { + organization_id: String, + member_id: String, + role: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationMemberSeatInput { + organization_id: String, + member_id: String, + enabled: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationMemberRemoveInput { + organization_id: String, + member_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationDeleteInput { + organization_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationDomainSetInput { + organization_id: String, + domain: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct OrganizationDomainInput { + organization_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SpaceInput { + space_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperAppInput { + app_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperVideoListInput { + app_id: String, + #[serde(default)] + user_id: Option, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperVideoDeleteInput { + app_id: String, + video_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperTransactionListInput { + app_id: String, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperAppUpdateInput { + app_id: String, + #[serde(default)] + name: Option, + #[serde(default)] + environment: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + clear_logo: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperAppDeleteInput { + app_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperDomainAddInput { + app_id: String, + domain: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperDomainRemoveInput { + app_id: String, + domain_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperAutoTopUpInput { + app_id: String, + enabled: bool, + #[serde(default)] + threshold_micro_credits: Option, + #[serde(default)] + amount_cents: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DeveloperCreditsCheckoutInput { + app_id: String, + amount_cents: u32, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct FolderListInput { + organization_id: String, + #[serde(default)] + space_id: Option, + #[serde(default)] + parent_id: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct NotificationListInput { + #[serde(default)] + unread: Option, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AnalyticsInput { + organization_id: String, + #[serde(default)] + space_id: Option, + #[serde(default)] + cap_id: Option, + #[serde(default)] + range: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AccountUpdateInput { + #[serde(default)] + name: Option, + #[serde(default)] + last_name: Option, + #[serde(default)] + default_organization_id: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AccountReferralsInput { + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct NotificationPreferencesInput { + #[serde(default)] + pause_comments: Option, + #[serde(default)] + pause_replies: Option, + #[serde(default)] + pause_views: Option, + #[serde(default)] + pause_reactions: Option, + #[serde(default)] + pause_anonymous_views: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct NotificationsReadInput { + #[serde(default)] + ids: Vec, + #[serde(default)] + all: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CapSettingsUpdateInput { + cap: String, + #[serde(default)] + disable_summary: Option, + #[serde(default)] + disable_captions: Option, + #[serde(default)] + disable_chapters: Option, + #[serde(default)] + disable_reactions: Option, + #[serde(default)] + disable_transcript: Option, + #[serde(default)] + disable_comments: Option, + #[serde(default)] + default_playback_speed: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CapMoveInput { + cap: String, + container: String, + organization_id: String, + #[serde(default)] + space_id: Option, + #[serde(default)] + folder_id: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CapShareInput { + cap: String, + target_type: String, + target_id: String, + #[serde(default)] + folder_id: Option, + #[serde(default)] + remove: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct FolderCreateInput { + organization_id: String, + name: String, + #[serde(default)] + color: Option, + #[serde(default)] + parent_id: Option, + #[serde(default)] + space_id: Option, + #[serde(default)] + public: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct FolderUpdateInput { + folder_id: String, + #[serde(default)] + name: Option, + #[serde(default)] + color: Option, + #[serde(default)] + parent_id: Option, + #[serde(default)] + move_to_root: bool, + #[serde(default)] + public: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct FolderDeleteInput { + folder_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct CollectionPublicPageInput { + kind: String, + collection_id: String, + #[serde(default)] + public: Option, + #[serde(default)] + title: Option, + #[serde(default)] + subtitle: Option, + #[serde(default)] + hide_title: Option, + #[serde(default)] + hide_copy_link: Option, + #[serde(default)] + logo_mode: Option, + #[serde(default)] + cta_label: Option, + #[serde(default)] + cta_url: Option, + #[serde(default)] + layout: Option, + #[serde(default)] + grid_columns: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SpaceCreateInput { + organization_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + privacy: Option, + #[serde(default)] + public: bool, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SpaceUpdateInput { + space_id: String, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + privacy: Option, + #[serde(default)] + public: Option, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SpaceDeleteInput { + space_id: String, + confirmed: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SpaceMemberMutationInput { + space_id: String, + user_id: String, + action: String, + #[serde(default)] + role: Option, + confirmed: bool, +} + +#[derive(Clone)] +struct CapMcpServer { + client: AgentClient, + tool_router: ToolRouter, +} + +impl CapMcpServer { + fn new(client: AgentClient) -> Self { + Self { + client, + tool_router: Self::tool_router(), + } + } + + fn result(result: Result) -> CallToolResult { + match result { + Ok(value) => CallToolResult::structured(value), + Err(mut error) => { + if error.code == "PASSWORD_REQUIRED" { + error.message = format!( + "{} Run `cap caps unlock` in a secure terminal; MCP never accepts passwords.", + error.message + ); + } + CallToolResult::structured_error(serde_json::to_value(error).unwrap_or_else(|_| { + json!({ + "code": "TEMPORARY_UNAVAILABLE", + "message": "The Cap response could not be represented", + "retryable": true, + }) + })) + } + } + } + + fn require_confirmation(confirmed: bool, action: &str) -> Option { + (!confirmed).then(|| { + Self::result(Err(AgentApiError { + code: "APPROVAL_REQUIRED".to_string(), + message: format!( + "Ask the user to confirm before you {action}, then retry with confirmed=true" + ), + retryable: false, + retry_after_ms: None, + request_id: None, + })) + }) + } + + fn invalid(message: impl Into) -> AgentApiError { + AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: message.into(), + retryable: false, + retry_after_ms: None, + request_id: None, + } + } + + async fn wait( + &self, + input: WaitInput, + context: &rmcp::service::RequestContext, + ) -> Result { + let id = cap_id(&input.cap)?; + let timeout = input.timeout_seconds.unwrap_or(600); + if timeout == 0 || timeout > 86_400 { + return Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "timeoutSeconds must be between 1 and 86400".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }); + } + let wait_for = input.wait_for.as_deref().unwrap_or("all"); + if !matches!(wait_for, "transcript" | "ai" | "all") { + return Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "waitFor must be transcript, ai, or all".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + let status_path = format!("/caps/{id}/status"); + let mut attempt = 0_u32; + loop { + let status = tokio::select! { + _ = context.ct.cancelled() => return Err(AgentApiError { + code: "TEMPORARY_UNAVAILABLE".to_string(), + message: "The MCP request was cancelled".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }), + result = self.client.get_json(&status_path) => result?, + }; + let state = |key: &str| { + status + .get(key) + .and_then(|value| value.get("status")) + .and_then(Value::as_str) + }; + let terminal = |key| { + matches!( + state(key), + Some("complete" | "skipped" | "no_audio" | "unavailable") + ) + }; + let failed = match wait_for { + "transcript" => state("transcript") == Some("error"), + "ai" => state("ai") == Some("error"), + _ => state("transcript") == Some("error") || state("ai") == Some("error"), + }; + if failed { + return Err(AgentApiError { + code: "NOT_READY".to_string(), + message: "Cap processing failed".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }); + } + let complete = match wait_for { + "transcript" => terminal("transcript"), + "ai" => terminal("ai"), + _ => terminal("transcript") && terminal("ai"), + }; + if complete { + return Ok(status); + } + if tokio::time::Instant::now() >= deadline { + return Err(AgentApiError { + code: "NOT_READY".to_string(), + message: "Timed out waiting for Cap processing".to_string(), + retryable: true, + retry_after_ms: Some(2_000), + request_id: None, + }); + } + let delay = 500_u64.saturating_mul(1_u64 << attempt.min(4)); + tokio::select! { + _ = context.ct.cancelled() => return Err(AgentApiError { + code: "TEMPORARY_UNAVAILABLE".to_string(), + message: "The MCP request was cancelled".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + }), + () = tokio::time::sleep(Duration::from_millis(delay.min(10_000))) => {} + } + attempt = attempt.saturating_add(1); + } + } + + async fn wait_operation( + &self, + input: OperationWaitInput, + context: &rmcp::service::RequestContext, + ) -> Result { + let operation_id = opaque_id(&input.operation_id, "Operation ID")?; + let timeout = input.timeout_seconds.unwrap_or(600); + if timeout == 0 || timeout > 86_400 { + return Err(Self::invalid("timeoutSeconds must be between 1 and 86400")); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + let path = format!("/operations/{operation_id}"); + let mut attempt = 0_u32; + loop { + let operation = tokio::select! { + _ = context.ct.cancelled() => return Err(Self::invalid("The MCP request was cancelled")), + result = self.client.get_json(&path) => result?, + }; + match operation.get("state").and_then(Value::as_str) { + Some("succeeded") => return Ok(operation), + Some("failed") => { + return Err(AgentApiError { + code: "OPERATION_FAILED".to_string(), + message: operation + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or("Cap operation failed") + .to_string(), + retryable: false, + retry_after_ms: None, + request_id: operation + .get("requestId") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + Some("queued" | "running") => {} + _ => return Err(Self::invalid("Cap returned an invalid operation state")), + } + if tokio::time::Instant::now() >= deadline { + return Err(AgentApiError { + code: "NOT_READY".to_string(), + message: "Timed out waiting for the Cap operation".to_string(), + retryable: true, + retry_after_ms: Some(2_000), + request_id: None, + }); + } + let delay = 500_u64.saturating_mul(1_u64 << attempt.min(4)); + tokio::select! { + _ = context.ct.cancelled() => return Err(Self::invalid("The MCP request was cancelled")), + () = tokio::time::sleep(Duration::from_millis(delay.min(10_000))) => {} + } + attempt = attempt.saturating_add(1); + } + } +} + +#[tool_router(router = tool_router)] +impl CapMcpServer { + #[tool( + name = "caps_list", + description = "List Caps visible in the authenticated personal library without loading transcripts or media URLs", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_list(&self, Parameters(input): Parameters) -> CallToolResult { + let path = match list_caps_path(input) { + Ok(path) => path, + Err(error) => return Self::result(Err(error)), + }; + Self::result(self.client.get_json(&path).await) + } + + #[tool( + name = "caps_get", + description = "Get lightweight Cap metadata, processing state, counts, and explicit capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_get(&self, Parameters(input): Parameters) -> CallToolResult { + let result = match cap_id(&input.cap) { + Ok(id) => self.client.get_json(&format!("/caps/{id}")).await, + Err(error) => Err(error), + }; + Self::result(result) + } + + #[tool( + name = "caps_context", + description = "Get Cap content and activity. Large transcript and activity fields are returned as cap:// resources", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_context(&self, Parameters(input): Parameters) -> CallToolResult { + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut context = match self.client.get_json(&format!("/caps/{id}/context")).await { + Ok(context) => context, + Err(error) => return Self::result(Err(error)), + }; + let serialized_size = serde_json::to_vec(&context).map_or(0, |value| value.len()); + if serialized_size <= 64 * 1024 { + return CallToolResult::structured(context); + } + let mut links = Vec::new(); + if let Some(object) = context.as_object_mut() { + for field in ["transcript", "comments", "reactions"] { + if object.contains_key(field) { + let uri = format!("cap://caps/{id}/{field}"); + object.insert(field.to_string(), json!({ "resourceUri": uri })); + links.push(ContentBlock::resource_link( + Resource::new(&uri, format!("Cap {field}")) + .with_mime_type("application/json"), + )); + } + } + } + let mut result = CallToolResult::structured(context); + result.content.extend(links); + result + } + + #[tool( + name = "caps_wait", + description = "Wait for existing transcript or AI processing to finish. This never starts processing", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_wait( + &self, + Parameters(input): Parameters, + context: rmcp::service::RequestContext, + ) -> CallToolResult { + Self::result(self.wait(input, &context).await) + } + + #[tool( + name = "caps_process", + description = "Explicitly start transcript or AI processing after user confirmation. This may incur paid work; reads and caps_wait never call it", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_process(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "start paid Cap processing") + { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let target = input.target.as_deref().unwrap_or("all"); + if !matches!(target, "transcript" | "ai" | "all") { + return Self::result(Err(Self::invalid("target must be transcript, ai, or all"))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/process"), + &json!({ "target": target, "retry": input.retry }), + ) + .await, + ) + } + + #[tool( + name = "caps_import_loom", + description = "Start a durable Loom import after explicit user confirmation; optional owner_email and space_name provide the per-row primitive for migration batches", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_import_loom( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "import the Loom video") { + return result; + } + let organization_id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{organization_id}/imports/loom"), + &json!({ + "loomUrl": input.loom_url, + "ownerEmail": input.owner_email, + "spaceName": input.space_name, + }), + ) + .await, + ) + } + + #[tool( + name = "caps_transcript_replace", + description = "Replace transcript cues using the revision from cap://caps/{id}/transcript after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_transcript_replace( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "replace this transcript") + { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PUT, + &format!("/caps/{id}/transcript"), + &json!({ + "expectedRevision": input.expected_revision, + "cues": input.cues, + }), + ) + .await, + ) + } + + #[tool( + name = "operations_get", + description = "Get the current state and result of an asynchronous Cap operation", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn operations_get( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let operation_id = match opaque_id(&input.operation_id, "Operation ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/operations/{operation_id}")) + .await, + ) + } + + #[tool( + name = "operations_wait", + description = "Wait for an existing durable Cap operation to finish without starting new work", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn operations_wait( + &self, + Parameters(input): Parameters, + context: rmcp::service::RequestContext, + ) -> CallToolResult { + Self::result(self.wait_operation(input, &context).await) + } + + #[tool( + name = "caps_duplicate", + description = "Queue a retry-safe Cap and media duplication after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_duplicate( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "duplicate this Cap") { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed(Method::POST, &format!("/caps/{id}/duplicate"), &json!({})) + .await, + ) + } + + #[tool( + name = "caps_delete", + description = "Permanently delete a Cap and its media through a retry-safe operation after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "permanently delete this Cap and its media") + { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed(Method::DELETE, &format!("/caps/{id}"), &json!({})) + .await, + ) + } + + #[tool( + name = "account_get", + description = "Get the authenticated Cap account and explicit account capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn account_get(&self) -> CallToolResult { + Self::result(self.client.get_json("/me").await) + } + + #[tool( + name = "account_referrals_open", + description = "Create a focused Dub referral-portal browser handoff after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn account_referrals_open( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "open the Cap referral portal") + { + return result; + } + Self::result( + self.client + .mutate_json_confirmed(Method::POST, "/me/referrals", &json!({})) + .await, + ) + } + + #[tool( + name = "organizations_list", + description = "List organizations visible to the authenticated account with roles, plan state, and capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organizations_list(&self) -> CallToolResult { + Self::result(self.client.get_json("/organizations").await) + } + + #[tool( + name = "organization_create", + description = "Create an organization after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_create( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "create an organization") + { + return result; + } + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + "/organizations", + &json!({ "name": input.name }), + ) + .await, + ) + } + + #[tool( + name = "organization_get", + description = "Get one organization and the authenticated account's capabilities in it", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_get( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result(self.client.get_json(&format!("/organizations/{id}")).await) + } + + #[tool( + name = "organization_members", + description = "List organization members and member-management capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_members( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/organizations/{id}/members")) + .await, + ) + } + + #[tool( + name = "organization_invites", + description = "List pending organization invitations without exposing invite secrets", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_invites( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/organizations/{id}/invites")) + .await, + ) + } + + #[tool( + name = "organization_billing", + description = "Get plan, subscription state, seat counts, and billing capabilities without initiating payment", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_billing( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/organizations/{id}/billing")) + .await, + ) + } + + #[tool( + name = "organization_billing_checkout", + description = "Create a Cap Pro checkout URL after explicit user confirmation; the user completes payment in a browser", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_billing_checkout( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "create a Cap Pro checkout") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let interval = input.interval.unwrap_or_else(|| "yearly".to_string()); + if !matches!(interval.as_str(), "monthly" | "yearly") { + return Self::result(Err(Self::invalid("interval must be monthly or yearly"))); + } + let mut body = Map::from_iter([("interval".to_string(), Value::String(interval))]); + if let Some(quantity) = input.quantity { + body.insert("quantity".to_string(), Value::from(quantity)); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/billing/checkout"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "organization_billing_portal", + description = "Create a Stripe billing-portal URL after explicit user confirmation; the user manages billing in a browser", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_billing_portal( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "open the Cap billing portal") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/billing/portal"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_storage_integrations", + description = "List storage integration metadata and status without returning credentials or signed URLs", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_storage_integrations( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/organizations/{id}/storage-integrations")) + .await, + ) + } + + #[tool( + name = "organization_storage_provider_set", + description = "Select S3 or Google Drive as the active organization storage provider after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_storage_provider_set( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "change the organization storage provider") + { + return result; + } + if !matches!(input.provider.as_str(), "s3" | "googleDrive") { + return Self::result(Err(Self::invalid("provider must be s3 or googleDrive"))); + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/organizations/{id}/storage/provider"), + &json!({ "provider": input.provider }), + ) + .await, + ) + } + + #[tool( + name = "organization_google_drive_connect", + description = "Create a Google Drive authorization URL after explicit user confirmation; the user completes OAuth in a browser", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_google_drive_connect( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "connect organization Google Drive") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/storage/google-drive/connect"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_google_drive_folders", + description = "List Google Drive folders available to an organization without returning OAuth credentials", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn organization_google_drive_folders( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let query = { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(parent_id) = input.parent_id { + serializer.append_pair("parentId", &parent_id); + } + serializer.finish() + }; + let path = if query.is_empty() { + format!("/organizations/{id}/storage/google-drive/folders") + } else { + format!("/organizations/{id}/storage/google-drive/folders?{query}") + }; + Self::result(self.client.get_json(&path).await) + } + + #[tool( + name = "organization_google_drive_location_set", + description = "Set the Google Drive folder used by an organization after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_google_drive_location_set( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation( + input.confirmed, + "change the organization Google Drive location", + ) { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PUT, + &format!("/organizations/{id}/storage/google-drive/location"), + &json!({ + "folderId": input.folder_id, + "folderName": input.folder_name, + "driveId": input.drive_id, + "driveName": input.drive_name, + }), + ) + .await, + ) + } + + #[tool( + name = "organization_google_drive_disconnect", + description = "Disconnect Google Drive from an organization after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_google_drive_disconnect( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "disconnect organization Google Drive") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/organizations/{id}/storage/google-drive"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_update", + description = "Update an organization name or allowed email domain after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "update the organization") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + if input.allowed_email_domain.is_some() && input.clear_allowed_email_domain { + return Self::result(Err(Self::invalid( + "allowedEmailDomain and clearAllowedEmailDomain cannot both be set", + ))); + } + let mut body = Map::new(); + if let Some(name) = input.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(domain) = input.allowed_email_domain { + body.insert("allowedEmailDomain".to_string(), Value::String(domain)); + } else if input.clear_allowed_email_domain { + body.insert("allowedEmailDomain".to_string(), Value::Null); + } + if body.is_empty() { + return Self::result(Err(Self::invalid( + "name or an allowed email domain change is required", + ))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/organizations/{id}"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "organization_settings_update", + description = "Update organization content and playback preferences after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_settings_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "update organization preferences") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::new(); + for (key, value) in [ + ("disableSummary", input.disable_summary), + ("disableCaptions", input.disable_captions), + ("disableChapters", input.disable_chapters), + ("disableReactions", input.disable_reactions), + ("disableTranscript", input.disable_transcript), + ("disableComments", input.disable_comments), + ( + "hideShareableLinkCapLogo", + input.hide_shareable_link_cap_logo, + ), + ( + "shareableLinkUseOrganizationIcon", + input.shareable_link_use_organization_icon, + ), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + if let Some(language) = input.ai_generation_language { + body.insert("aiGenerationLanguage".to_string(), Value::String(language)); + } + if let Some(speed) = input.default_playback_speed { + let Some(speed) = serde_json::Number::from_f64(speed) else { + return Self::result(Err(Self::invalid("defaultPlaybackSpeed must be finite"))); + }; + body.insert("defaultPlaybackSpeed".to_string(), Value::Number(speed)); + } + if body.is_empty() { + return Self::result(Err(Self::invalid( + "at least one organization preference is required", + ))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/organizations/{id}/settings"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "organization_invite_add", + description = "Create an organization invite and deliver its email by default after explicit user confirmation; set send_email=false for link-only provisioning", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_invite_add( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "create the organization invite") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let role = input.role.as_deref().unwrap_or("member"); + if !matches!(role, "admin" | "member") { + return Self::result(Err(Self::invalid("role must be admin or member"))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/invites"), + &json!({ + "email": input.email, + "role": role, + "sendEmail": input.send_email.unwrap_or(true), + }), + ) + .await, + ) + } + + #[tool( + name = "organization_invite_remove", + description = "Remove a pending organization invite after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_invite_remove( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "remove the organization invite") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let invite_id = match opaque_id(&input.invite_id, "Invite ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/organizations/{id}/invites/{invite_id}"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_member_role", + description = "Change an organization member role after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_member_role( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "change the organization member role") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let member_id = match opaque_id(&input.member_id, "Member ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + if !matches!(input.role.as_str(), "admin" | "member") { + return Self::result(Err(Self::invalid("role must be admin or member"))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/organizations/{id}/members/{member_id}"), + &json!({ "role": input.role }), + ) + .await, + ) + } + + #[tool( + name = "organization_member_seat", + description = "Assign or remove an organization member Pro seat after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_member_seat( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "change the organization member Pro seat") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let member_id = match opaque_id(&input.member_id, "Member ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/organizations/{id}/members/{member_id}/seat"), + &json!({ "enabled": input.enabled }), + ) + .await, + ) + } + + #[tool( + name = "organization_member_remove", + description = "Remove an organization member and their space memberships after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_member_remove( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "remove the organization member") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let member_id = match opaque_id(&input.member_id, "Member ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/organizations/{id}/members/{member_id}"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_delete", + description = "Permanently delete an organization and all of its Caps after explicit user confirmation; returns an asynchronous operation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation( + input.confirmed, + "permanently delete the organization and all of its Caps", + ) { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed(Method::DELETE, &format!("/organizations/{id}"), &json!({})) + .await, + ) + } + + #[tool( + name = "organization_domain_set", + description = "Configure an organization custom domain after explicit user confirmation; returns an asynchronous operation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_domain_set( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "set the organization custom domain") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PUT, + &format!("/organizations/{id}/domain"), + &json!({ "domain": input.domain }), + ) + .await, + ) + } + + #[tool( + name = "organization_domain_remove", + description = "Remove an organization custom domain after explicit user confirmation; returns an asynchronous operation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_domain_remove( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "remove the organization custom domain") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/organizations/{id}/domain"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "organization_domain_verify", + description = "Recheck organization custom-domain DNS and verification after explicit user confirmation; returns an asynchronous operation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn organization_domain_verify( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "verify the organization custom domain") + { + return result; + } + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/organizations/{id}/domain/verify"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "folders_list", + description = "List folders in a personal, organization, or space container", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn folders_list(&self, Parameters(input): Parameters) -> CallToolResult { + let organization = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let path = { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + if let Some(space_id) = input.space_id { + query.append_pair("spaceId", &space_id); + } + if let Some(parent_id) = input.parent_id { + query.append_pair("parentId", &parent_id); + } + format!("/organizations/{organization}/folders?{}", query.finish()) + }; + Self::result(self.client.get_json(&path).await) + } + + #[tool( + name = "spaces_list", + description = "List spaces visible in an organization with counts, roles, and capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn spaces_list( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/organizations/{id}/spaces")) + .await, + ) + } + + #[tool( + name = "space_members", + description = "List members and roles for a visible space", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn space_members(&self, Parameters(input): Parameters) -> CallToolResult { + let id = match opaque_id(&input.space_id, "Space ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result(self.client.get_json(&format!("/spaces/{id}/members")).await) + } + + #[tool( + name = "notifications_list", + description = "List notifications for the authenticated recipient with cursor pagination", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn notifications_list( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let limit = input.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Self::result(Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "limit must be between 1 and 100".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + })); + } + let path = { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("limit", &limit.to_string()); + if let Some(unread) = input.unread { + query.append_pair("unread", if unread { "true" } else { "false" }); + } + if let Some(cursor) = input.cursor { + query.append_pair("cursor", &cursor); + } + format!("/me/notifications?{}", query.finish()) + }; + Self::result(self.client.get_json(&path).await) + } + + #[tool( + name = "notification_preferences_get", + description = "Get notification preferences for the authenticated account", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn notification_preferences_get(&self) -> CallToolResult { + Self::result(self.client.get_json("/me/notification-preferences").await) + } + + #[tool( + name = "analytics_get", + description = "Get tenant-bound organization, space, or Cap analytics for an explicit time range", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn analytics_get(&self, Parameters(input): Parameters) -> CallToolResult { + let range = input.range.unwrap_or_else(|| "month".to_string()); + if !matches!(range.as_str(), "day" | "week" | "month" | "year") { + return Self::result(Err(AgentApiError { + code: "INVALID_REQUEST".to_string(), + message: "range must be day, week, month, or year".to_string(), + retryable: false, + retry_after_ms: None, + request_id: None, + })); + } + let path = { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("organizationId", &input.organization_id); + query.append_pair("range", &range); + if let Some(space_id) = input.space_id { + query.append_pair("spaceId", &space_id); + } + if let Some(cap_id) = input.cap_id { + query.append_pair("capId", &cap_id); + } + format!("/analytics?{}", query.finish()) + }; + Self::result(self.client.get_json(&path).await) + } + + #[tool( + name = "developer_apps_list", + description = "List developer apps without returning API key material", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn developer_apps_list(&self) -> CallToolResult { + Self::result(self.client.get_json("/developer/apps").await) + } + + #[tool( + name = "developer_app_context", + description = "Get developer app domains, key metadata, usage, storage, and credits without exposing secrets", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn developer_app_context( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .get_json(&format!("/developer/apps/{id}/context")) + .await, + ) + } + + #[tool( + name = "developer_videos_list", + description = "List SDK videos for a developer app with cursor pagination and an optional external user ID filter", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn developer_videos_list( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let limit = input.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Self::result(Err(Self::invalid("limit must be between 1 and 100"))); + } + let query = { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("limit", &limit.to_string()); + if let Some(user_id) = input.user_id { + serializer.append_pair("userId", &user_id); + } + if let Some(cursor) = input.cursor { + serializer.append_pair("cursor", &cursor); + } + serializer.finish() + }; + Self::result( + self.client + .get_json(&format!("/developer/apps/{id}/videos?{query}")) + .await, + ) + } + + #[tool( + name = "developer_transactions_list", + description = "List developer credit transactions with cursor pagination", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn developer_transactions_list( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let limit = input.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Self::result(Err(Self::invalid("limit must be between 1 and 100"))); + } + let query = { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("limit", &limit.to_string()); + if let Some(cursor) = input.cursor { + serializer.append_pair("cursor", &cursor); + } + serializer.finish() + }; + Self::result( + self.client + .get_json(&format!("/developer/apps/{id}/transactions?{query}")) + .await, + ) + } + + #[tool( + name = "developer_video_delete", + description = "Delete an SDK video from a developer app after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_video_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "delete the developer SDK video") + { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let video_id = match opaque_id(&input.video_id, "Developer video ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/developer/apps/{id}/videos/{video_id}"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "developer_app_update", + description = "Update developer app metadata after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_app_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "update the developer app") + { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + if input.logo_url.is_some() && input.clear_logo { + return Self::result(Err(Self::invalid( + "logoUrl and clearLogo cannot both be set", + ))); + } + let mut body = Map::new(); + if let Some(name) = input.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(environment) = input.environment { + if !matches!(environment.as_str(), "development" | "production") { + return Self::result(Err(Self::invalid( + "environment must be development or production", + ))); + } + body.insert("environment".to_string(), Value::String(environment)); + } + if let Some(logo_url) = input.logo_url { + body.insert("logoUrl".to_string(), Value::String(logo_url)); + } else if input.clear_logo { + body.insert("logoUrl".to_string(), Value::Null); + } + if body.is_empty() { + return Self::result(Err(Self::invalid( + "at least one developer app update is required", + ))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/developer/apps/{id}"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "developer_app_delete", + description = "Delete a developer app and revoke its keys after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_app_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation( + input.confirmed, + "delete the developer app and revoke its keys", + ) { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed(Method::DELETE, &format!("/developer/apps/{id}"), &json!({})) + .await, + ) + } + + #[tool( + name = "developer_domain_add", + description = "Add an allowed origin to a developer app after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_domain_add( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "add the developer app domain") + { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/developer/apps/{id}/domains"), + &json!({ "domain": input.domain }), + ) + .await, + ) + } + + #[tool( + name = "developer_domain_remove", + description = "Remove an allowed origin from a developer app after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_domain_remove( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "remove the developer app domain") + { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let domain_id = match opaque_id(&input.domain_id, "Developer domain ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::DELETE, + &format!("/developer/apps/{id}/domains/{domain_id}"), + &json!({}), + ) + .await, + ) + } + + #[tool( + name = "developer_auto_top_up_update", + description = "Configure future automatic developer credit purchases after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_auto_top_up_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation( + input.confirmed, + "change automatic developer credit purchases", + ) { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::from_iter([("enabled".to_string(), Value::Bool(input.enabled))]); + if let Some(value) = input.threshold_micro_credits { + body.insert("thresholdMicroCredits".to_string(), Value::from(value)); + } + if let Some(value) = input.amount_cents { + body.insert("amountCents".to_string(), Value::from(value)); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/developer/apps/{id}/auto-top-up"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "developer_credits_checkout", + description = "Create a developer-credit purchase URL after explicit user confirmation; the user completes payment in a browser", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn developer_credits_checkout( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "create a developer credit checkout") + { + return result; + } + let id = match opaque_id(&input.app_id, "Developer app ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/developer/apps/{id}/credits/checkout"), + &json!({ "amountCents": input.amount_cents }), + ) + .await, + ) + } + + #[tool( + name = "account_update", + description = "Update account profile fields after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn account_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "update the account") { + return result; + } + let mut body = Map::new(); + if let Some(name) = input.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(last_name) = input.last_name { + body.insert("lastName".to_string(), Value::String(last_name)); + } + if let Some(organization_id) = input.default_organization_id { + body.insert( + "defaultOrganizationId".to_string(), + Value::String(organization_id), + ); + } + Self::result( + self.client + .mutate_json(Method::PATCH, "/me", &Value::Object(body)) + .await, + ) + } + + #[tool( + name = "notification_preferences_update", + description = "Update notification preferences after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn notification_preferences_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "update notification preferences") + { + return result; + } + let mut body = Map::new(); + for (key, value) in [ + ("pauseComments", input.pause_comments), + ("pauseReplies", input.pause_replies), + ("pauseViews", input.pause_views), + ("pauseReactions", input.pause_reactions), + ("pauseAnonymousViews", input.pause_anonymous_views), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + "/me/notification-preferences", + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "notifications_mark_read", + description = "Mark selected or all notifications read after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn notifications_mark_read( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "mark notifications as read") + { + return result; + } + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + "/me/notifications/read", + &json!({ "ids": input.ids, "all": input.all }), + ) + .await, + ) + } + + #[tool( + name = "caps_settings_get", + description = "Get Cap viewer-setting overrides, effective settings, inheritance, and capabilities", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_settings_get(&self, Parameters(input): Parameters) -> CallToolResult { + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result(self.client.get_json(&format!("/caps/{id}/settings")).await) + } + + #[tool( + name = "caps_settings_update", + description = "Update Cap viewer settings after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_settings_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "update these Cap viewer settings") + { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::new(); + for (key, value) in [ + ("disableSummary", input.disable_summary), + ("disableCaptions", input.disable_captions), + ("disableChapters", input.disable_chapters), + ("disableReactions", input.disable_reactions), + ("disableTranscript", input.disable_transcript), + ("disableComments", input.disable_comments), + ] { + if let Some(value) = value { + body.insert(key.to_string(), Value::Bool(value)); + } + } + if let Some(speed) = input.default_playback_speed { + body.insert("defaultPlaybackSpeed".to_string(), json!(speed)); + } + if body.is_empty() { + return Self::result(Err(Self::invalid("Provide at least one Cap setting"))); + } + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/caps/{id}/settings"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "caps_shares_get", + description = "Get explicit organization and space sharing targets for an owned Cap", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn caps_shares_get(&self, Parameters(input): Parameters) -> CallToolResult { + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result(self.client.get_json(&format!("/caps/{id}/shares")).await) + } + + #[tool( + name = "caps_move", + description = "Move an owned Cap within its personal, organization, or space container after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_move(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "move this Cap") { + return result; + } + if !matches!( + input.container.as_str(), + "personal" | "organization" | "space" + ) { + return Self::result(Err(Self::invalid( + "container must be personal, organization, or space", + ))); + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json( + Method::PATCH, + &format!("/caps/{id}/location"), + &json!({ + "container": input.container, + "organizationId": input.organization_id, + "spaceId": input.space_id, + "folderId": input.folder_id, + }), + ) + .await, + ) + } + + #[tool( + name = "caps_share_set", + description = "Add, update, or remove an organization or space share after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_share_set(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "change this Cap share") { + return result; + } + let segment = match input.target_type.as_str() { + "organization" => "organizations", + "space" => "spaces", + _ => { + return Self::result(Err(Self::invalid( + "targetType must be organization or space", + ))); + } + }; + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let target = match opaque_id(&input.target_id, "Share target ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json( + if input.remove { + Method::DELETE + } else { + Method::PUT + }, + &format!("/caps/{id}/shares/{segment}/{target}"), + &json!({ "folderId": input.folder_id }), + ) + .await, + ) + } + + #[tool( + name = "folder_create", + description = "Create a folder in an explicit personal, organization, or space container after user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn folder_create( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "create this folder") { + return result; + } + let color = input.color.unwrap_or_else(|| "normal".to_string()); + if !matches!(color.as_str(), "normal" | "blue" | "red" | "yellow") { + return Self::result(Err(Self::invalid( + "color must be normal, blue, red, or yellow", + ))); + } + let organization = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json( + Method::POST, + &format!("/organizations/{organization}/folders"), + &json!({ + "name": input.name, + "color": color, + "parentId": input.parent_id, + "spaceId": input.space_id, + "public": input.public, + }), + ) + .await, + ) + } + + #[tool( + name = "folder_update", + description = "Update or move a folder after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn folder_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "update this folder") { + return result; + } + if let Some(color) = input.color.as_deref() + && !matches!(color, "normal" | "blue" | "red" | "yellow") + { + return Self::result(Err(Self::invalid( + "color must be normal, blue, red, or yellow", + ))); + } + let folder = match opaque_id(&input.folder_id, "Folder ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::new(); + if let Some(name) = input.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(color) = input.color { + body.insert("color".to_string(), Value::String(color)); + } + if input.move_to_root { + body.insert("parentId".to_string(), Value::Null); + } else if let Some(parent_id) = input.parent_id { + body.insert("parentId".to_string(), Value::String(parent_id)); + } + if let Some(public) = input.public { + body.insert("public".to_string(), Value::Bool(public)); + } + if body.is_empty() { + return Self::result(Err(Self::invalid("Provide at least one folder update"))); + } + Self::result( + self.client + .mutate_json( + Method::PATCH, + &format!("/folders/{folder}"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "collection_public_page_update", + description = "Update a folder or space public page and visibility after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn collection_public_page_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "update the public collection page") + { + return result; + } + if !matches!(input.kind.as_str(), "folder" | "space") { + return Self::result(Err(Self::invalid("kind must be folder or space"))); + } + if input + .logo_mode + .as_deref() + .is_some_and(|value| !matches!(value, "cap" | "organization" | "custom" | "none")) + { + return Self::result(Err(Self::invalid( + "logoMode must be cap, organization, custom, or none", + ))); + } + if input + .layout + .as_deref() + .is_some_and(|value| !matches!(value, "grid" | "list")) + { + return Self::result(Err(Self::invalid("layout must be grid or list"))); + } + if input + .grid_columns + .is_some_and(|value| !(2..=5).contains(&value)) + { + return Self::result(Err(Self::invalid("gridColumns must be between 2 and 5"))); + } + let id = match opaque_id(&input.collection_id, "Collection ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::new(); + for (key, value) in [ + ("public", input.public.map(Value::Bool)), + ("title", input.title.map(Value::String)), + ("subtitle", input.subtitle.map(Value::String)), + ("hideTitle", input.hide_title.map(Value::Bool)), + ("hideCopyLink", input.hide_copy_link.map(Value::Bool)), + ("logoMode", input.logo_mode.map(Value::String)), + ("ctaLabel", input.cta_label.map(Value::String)), + ("ctaUrl", input.cta_url.map(Value::String)), + ("layout", input.layout.map(Value::String)), + ("gridColumns", input.grid_columns.map(Value::from)), + ] { + if let Some(value) = value { + body.insert(key.to_string(), value); + } + } + if body.is_empty() { + return Self::result(Err(Self::invalid( + "at least one public page update is required", + ))); + } + let kind = if input.kind == "folder" { + "folders" + } else { + "spaces" + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/{kind}/{id}/public-page"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "folder_delete", + description = "Delete a folder and move contained Caps to its parent after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn folder_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "delete this folder") { + return result; + } + let folder = match opaque_id(&input.folder_id, "Folder ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json(Method::DELETE, &format!("/folders/{folder}"), &json!({})) + .await, + ) + } + + #[tool( + name = "space_create", + description = "Create a space after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn space_create( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "create this space") { + return result; + } + let privacy = input.privacy.unwrap_or_else(|| "Private".to_string()); + if !matches!(privacy.as_str(), "Public" | "Private") { + return Self::result(Err(Self::invalid("privacy must be Public or Private"))); + } + let organization = match opaque_id(&input.organization_id, "Organization ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json( + Method::POST, + &format!("/organizations/{organization}/spaces"), + &json!({ + "name": input.name, + "description": input.description, + "privacy": privacy, + "public": input.public, + }), + ) + .await, + ) + } + + #[tool( + name = "space_update", + description = "Update a space after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn space_update( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "update this space") { + return result; + } + if let Some(privacy) = input.privacy.as_deref() + && !matches!(privacy, "Public" | "Private") + { + return Self::result(Err(Self::invalid("privacy must be Public or Private"))); + } + let space = match opaque_id(&input.space_id, "Space ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let mut body = Map::new(); + if let Some(name) = input.name { + body.insert("name".to_string(), Value::String(name)); + } + if let Some(description) = input.description { + body.insert("description".to_string(), Value::String(description)); + } + if let Some(privacy) = input.privacy { + body.insert("privacy".to_string(), Value::String(privacy)); + } + if let Some(public) = input.public { + body.insert("public".to_string(), Value::Bool(public)); + } + if body.is_empty() { + return Self::result(Err(Self::invalid("Provide at least one space update"))); + } + Self::result( + self.client + .mutate_json( + Method::PATCH, + &format!("/spaces/{space}"), + &Value::Object(body), + ) + .await, + ) + } + + #[tool( + name = "space_delete", + description = "Permanently delete a non-primary space after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn space_delete( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "delete this space") { + return result; + } + let space = match opaque_id(&input.space_id, "Space ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json(Method::DELETE, &format!("/spaces/{space}"), &json!({})) + .await, + ) + } + + #[tool( + name = "space_member_set", + description = "Add, update, or remove a space member after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn space_member_set( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "change this space member") + { + return result; + } + let space = match opaque_id(&input.space_id, "Space ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let user = match opaque_id(&input.user_id, "User ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let (method, path, body) = match input.action.as_str() { + "add" => ( + Method::POST, + format!("/spaces/{space}/members"), + json!({ "userId": user, "role": input.role.unwrap_or_else(|| "member".to_string()) }), + ), + "update" => { + let Some(role) = input.role else { + return Self::result(Err(Self::invalid( + "role is required when action is update", + ))); + }; + ( + Method::PATCH, + format!("/spaces/{space}/members/{user}"), + json!({ "role": role }), + ) + } + "remove" => ( + Method::DELETE, + format!("/spaces/{space}/members/{user}"), + json!({}), + ), + _ => { + return Self::result(Err(Self::invalid("action must be add, update, or remove"))); + } + }; + Self::result(self.client.mutate_json(method, &path, &body).await) + } + + #[tool( + name = "caps_comment", + description = "Post a comment after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_comment(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "post this comment") { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/comments"), + &json!({ "content": input.content, "timestampMs": input.timestamp_ms }), + ) + .await, + ) + } + + #[tool( + name = "caps_reply", + description = "Reply to a comment after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_reply(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "post this reply") { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + let comment_id = match opaque_id(&input.comment_id, "Comment ID") { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/comments/{comment_id}/replies"), + &json!({ "content": input.content, "timestampMs": input.timestamp_ms }), + ) + .await, + ) + } + + #[tool( + name = "caps_react", + description = "Add a reaction after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_react(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "post this reaction") { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::POST, + &format!("/caps/{id}/reactions"), + &json!({ "content": input.content, "timestampMs": input.timestamp_ms }), + ) + .await, + ) + } + + #[tool( + name = "caps_update_title", + description = "Change a Cap title after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_update_title(&self, Parameters(input): Parameters) -> CallToolResult { + if let Some(result) = Self::require_confirmation(input.confirmed, "change this Cap title") { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/caps/{id}"), + &json!({ "title": input.title }), + ) + .await, + ) + } + + #[tool( + name = "caps_set_visibility", + description = "Set a Cap public or private after explicit user confirmation", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn caps_set_visibility( + &self, + Parameters(input): Parameters, + ) -> CallToolResult { + if let Some(result) = + Self::require_confirmation(input.confirmed, "change this Cap visibility") + { + return result; + } + let id = match cap_id(&input.cap) { + Ok(id) => id, + Err(error) => return Self::result(Err(error)), + }; + Self::result( + self.client + .mutate_json_confirmed( + Method::PATCH, + &format!("/caps/{id}"), + &json!({ "public": input.public }), + ) + .await, + ) + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for CapMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new("cap", env!("CARGO_PKG_VERSION"))) + .with_instructions( + "Use Cap resources for large transcript and activity data. Passwords, S3 credentials, image files, and newly issued developer credentials are never accepted or returned by MCP; use the corresponding `cap caps`, `cap organizations`, `cap account`, or `cap developers` command in a secure terminal.", + ) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult::with_all_items( + ["transcript", "comments", "reactions"] + .into_iter() + .map(|field| { + ResourceTemplate::new( + format!("cap://caps/{{id}}/{field}"), + format!("Cap {field}"), + ) + .with_description(format!("Full {field} content for a Cap")) + .with_mime_type("application/json") + }) + .collect(), + )) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + let url = url::Url::parse(&request.uri) + .map_err(|_| ErrorData::invalid_params("Invalid Cap resource URI", None))?; + if url.scheme() != "cap" || url.host_str() != Some("caps") { + return Err(ErrorData::resource_not_found("Unknown Cap resource", None)); + } + let segments = url + .path_segments() + .map(|segments| segments.filter(|part| !part.is_empty()).collect::>()) + .unwrap_or_default(); + let [id, field] = segments.as_slice() else { + return Err(ErrorData::resource_not_found("Unknown Cap resource", None)); + }; + if !matches!(*field, "transcript" | "comments" | "reactions") { + return Err(ErrorData::resource_not_found("Unknown Cap resource", None)); + } + let id = cap_id(id).map_err(|error| { + ErrorData::invalid_params(error.message.clone(), serde_json::to_value(error).ok()) + })?; + let path = if *field == "transcript" { + format!("/caps/{id}/transcript?format=json") + } else { + format!("/caps/{id}/context") + }; + let resource = self.client.get_json(&path).await.map_err(|error| { + let message = if error.code == "PASSWORD_REQUIRED" { + "PASSWORD_REQUIRED: run `cap caps unlock` in a secure terminal".to_string() + } else { + error.to_string() + }; + ErrorData::invalid_params(message, serde_json::to_value(error).ok()) + })?; + let content = if *field == "transcript" { + &resource + } else { + resource + .get(*field) + .ok_or_else(|| ErrorData::resource_not_found("Cap resource is unavailable", None))? + }; + let text = serde_json::to_string(content) + .map_err(|error| ErrorData::internal_error(error.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, request.uri).with_mime_type("application/json"), + ])) + } +} + +impl McpArgs { + pub async fn run(self) -> Result<(), String> { + match self.command { + McpCommands::Serve => { + let client = AgentClient::from_credentials().map_err(|error| error.to_string())?; + let service = CapMcpServer::new(client) + .serve(rmcp::transport::stdio()) + .await + .map_err(|error| error.to_string())?; + service.waiting().await.map_err(|error| error.to_string())?; + Ok(()) + } + } + } +} From a9dbcdc4a86d4392985d3be62beddd9e7dffdb14 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 42/58] feat(cli): update CLI guide for agent commands --- apps/cli/src/guide.rs | 144 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/guide.rs b/apps/cli/src/guide.rs index 32cc01e0a24..2d018d7d929 100644 --- a/apps/cli/src/guide.rs +++ b/apps/cli/src/guide.rs @@ -1,6 +1,8 @@ use serde::Serialize; -use crate::{OutputFormat, doctor::SCHEMA_VERSION, write_json}; +use crate::{OutputFormat, write_json}; + +const GUIDE_SCHEMA_VERSION: u32 = 3; /// Machine-readable capability + schema manifest. `cap guide --json` is the single document an agent /// can fetch to learn the output convention, env vars, exit codes, and the per-command output shape @@ -92,7 +94,7 @@ const fn cmd( fn build() -> Guide { Guide { - schema_version: SCHEMA_VERSION, + schema_version: GUIDE_SCHEMA_VERSION, binary: env!("CARGO_PKG_NAME"), version: env!("CARGO_PKG_VERSION"), description: "Cap screen recording, driven from the command line. Add --json to any command \ @@ -115,9 +117,15 @@ fn build() -> Guide { EnvVar { name: "CAP_SERVER_URL", required: false, - used_by: "upload", + used_by: "upload, auth login, caps, mcp", description: "Cap server base URL. Defaults to https://cap.so.", }, + EnvVar { + name: "CAP_AGENT_TOKEN", + required: false, + used_by: "caps, mcp", + description: "Overrides the OS-stored Cap agent credential for headless use.", + }, EnvVar { name: "CAP_NO_MODIFY_PATH", required: false, @@ -215,7 +223,133 @@ fn build() -> Guide { ), cmd( "auth status", - "Report whether uploads are authenticated and the source (desktop login or CAP_API_KEY).", + "Report credential presence and verify Cap CLI agent credentials with the configured server.", + OutputMode::SingleJson, + &[], + ), + cmd( + "auth login|logout", + "Authorize with browser approval and PKCE, or revoke the OS-stored Cap agent credential.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps list|get|context|status", + "Read the authenticated Cap library. `get` is lightweight; `context` includes content and activity.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps wait", + "Wait for existing transcript or AI processing with backoff. Never starts processing.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps transcript|download", + "Stream a transcript or video to a temporary file and atomically rename it to --output.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps process|transcript-replace", + "Explicitly start owner-authorized processing or replace a transcript with optimistic revision checking.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps import loom", + "Start a durable Loom import. Optional owner and space assignment provide a retry-safe per-row primitive for migration batches.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps duplicate|delete|password", + "Run confirmed Cap lifecycle operations or securely set and clear Cap passwords. Duplicate and delete can be observed with jobs wait.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps unlock", + "Securely unlock a password-protected Cap with an interactive prompt or --password-stdin; stores only a short-lived access grant.", + OutputMode::SingleJson, + &[], + ), + cmd( + "caps comments|reactions|update|sharing", + "Create idempotent feedback or change an owner Cap title or visibility. Capabilities remain server-enforced.", + OutputMode::SingleJson, + &[], + ), + cmd( + "account get|update|image|referrals|sign-out-all", + "Read and manage the authenticated Cap profile, image, default organization, referral provider handoff, and active sessions. Image files and global sign-out require confirmed CLI commands.", + OutputMode::SingleJson, + &[], + ), + cmd( + "organizations list|get|members|invites", + "Inspect organizations, membership, invitations, billing state, and storage integrations.", + OutputMode::SingleJson, + &[], + ), + cmd( + "organizations create|update|icon|shareable-icon|settings|invite|member|domain|delete", + "Manage organization lifecycle, branding, preferences, email-delivered or link-only invitations, roles, membership, Pro seats, custom domains, and durable deletion jobs.", + OutputMode::SingleJson, + &[], + ), + cmd( + "organizations billing get|checkout|portal", + "Inspect billing or create a confirmed browser handoff for Cap Pro checkout and the billing portal.", + OutputMode::SingleJson, + &[], + ), + cmd( + "organizations storage list|s3|provider|google-drive", + "Inspect and manage S3 or Google Drive storage. S3 credentials are accepted only by secure CLI input; Google OAuth uses a browser handoff.", + OutputMode::SingleJson, + &[], + ), + cmd( + "library folders|spaces", + "List and manage folders, spaces, public collection pages and logos, sharing locations, and space membership.", + OutputMode::SingleJson, + &[], + ), + cmd( + "notifications list|preferences|read", + "Read notifications, update notification preferences, or mark notifications read.", + OutputMode::SingleJson, + &[], + ), + cmd( + "analytics get", + "Read organization, space, or Cap analytics for a bounded time range.", + OutputMode::SingleJson, + &[], + ), + cmd( + "developers list|get|create|update|delete|domains|keys|auto-top-up|credits|videos|transactions", + "Manage developer apps, SDK videos, credit history, domains, credentials, auto top-up, and credit checkout. New credentials are returned only by confirmed CLI commands.", + OutputMode::SingleJson, + &[], + ), + cmd( + "jobs get|wait", + "Inspect or wait for asynchronous Cap operations without starting new work.", + OutputMode::SingleJson, + &[], + ), + cmd( + "mcp serve", + "Run the local stdio MCP server. Stdout is reserved exclusively for protocol traffic.", + OutputMode::TextOnly, + &[], + ), + cmd( + "agents install", + "Preview and install the Cap skill, MCP configuration, or both for one explicit agent target.", OutputMode::SingleJson, &[], ), @@ -276,6 +410,8 @@ fn build() -> Guide { "Automations authored in Cap Desktop run automatically after `cap screenshot`, `cap record` \ finishes, and `cap upload`. Clipboard/OCR/notification/editor actions are desktop-only and \ are skipped on the CLI. List them with `cap automations list`.", + "Cap library reads and waits never start transcription, AI generation, or other paid processing.", + "MCP never accepts passwords, S3 credentials, image files, or newly issued developer credentials. Use confirmed secure CLI commands for those values.", ], } } From 00bf8f9f9f3dbd822d2a45a0c375befe0f3df7fc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 43/58] feat(cli): wire agent commands in main entrypoint --- apps/cli/src/main.rs | 52 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index af1f9e4f241..cd77ed9722a 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,8 +1,22 @@ +mod account; +mod agent_auth; +mod agent_client; +mod agents; +mod analytics; +mod atomic; mod automation; +mod caps; +mod confirmation; mod credentials; +mod developers; mod doctor; mod export; mod guide; +mod jobs; +mod library; +mod mcp; +mod notifications; +mod organizations; mod project; mod record; mod recordings; @@ -193,6 +207,26 @@ enum Commands { Update(FormatArgs), /// Show how `cap upload` will authenticate (env key or Cap Desktop login) Auth(AuthArgs), + /// Read and manage Caps in your personal library + Caps(caps::CapsArgs), + /// Read or update the authenticated Cap account + Account(account::AccountArgs), + /// Inspect Cap organizations, members, billing, and storage connections + Organizations(organizations::OrganizationsArgs), + /// Manage folders, spaces, and space membership + Library(library::LibraryArgs), + /// Read and manage account notifications + Notifications(notifications::NotificationsArgs), + /// Read organization, space, or Cap analytics + Analytics(analytics::AnalyticsArgs), + /// Inspect developer apps, domains, usage, and credits + Developers(developers::DevelopersArgs), + /// Inspect or wait for asynchronous Cap operations + Jobs(jobs::JobsArgs), + /// Run Cap's local Model Context Protocol server + Mcp(mcp::McpArgs), + /// Install Cap integrations for one explicitly selected agent + Agents(agents::AgentsArgs), /// List available capture targets and devices Targets(TargetsArgs), /// Report CLI environment and capture-readiness diagnostics @@ -388,6 +422,10 @@ struct AuthArgs { enum AuthCommands { /// Report whether a credential is available and where it comes from (never prints the secret) Status(FormatArgs), + /// Authorize Cap CLI in the browser using PKCE + Login(agent_auth::LoginArgs), + /// Revoke and remove the Cap CLI credential + Logout(agent_auth::LogoutArgs), } #[derive(Args)] @@ -555,9 +593,21 @@ async fn run(cli: Cli) -> Result<(), String> { Commands::Auth(args) => match args.command { AuthCommands::Status(args) => { let format = resolve_format(json, args.format); - finish_json(format, credentials::status(format)) + finish_json(format, credentials::status(format).await) } + AuthCommands::Login(args) => args.run(json).await, + AuthCommands::Logout(args) => args.run(json).await, }, + Commands::Caps(args) => args.run(json).await, + Commands::Account(args) => args.run(json).await, + Commands::Organizations(args) => args.run(json).await, + Commands::Library(args) => args.run(json).await, + Commands::Notifications(args) => args.run(json).await, + Commands::Analytics(args) => args.run(json).await, + Commands::Developers(args) => args.run(json).await, + Commands::Jobs(args) => args.run(json).await, + Commands::Mcp(args) => args.run().await, + Commands::Agents(args) => args.run(json), Commands::Targets(args) => args.run(json), Commands::Doctor(args) => doctor::run_doctor(resolve_format(json, args.format)).await, Commands::Version(args) => { From 9c787418a9d37d3129340d59ff658310d4a6e066 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 44/58] docs(cli): update Cap skill documentation --- apps/cli/skill/cap/SKILL.md | 129 +++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/apps/cli/skill/cap/SKILL.md b/apps/cli/skill/cap/SKILL.md index 606a2c75fef..3da734a3b5f 100644 --- a/apps/cli/skill/cap/SKILL.md +++ b/apps/cli/skill/cap/SKILL.md @@ -1,94 +1,99 @@ --- name: cap description: >- - Record the screen, capture screenshots, and create shareable video links from the command line using - the Cap CLI. Use when the user wants to record a screen demo or bug repro, capture a screenshot of a - screen/window, produce a Loom-style shareable video link, or automate/script screen recording. Requires - the `cap` command to be installed (Cap Desktop, https://cap.so). + Use Cap from the command line or local MCP to record, export, upload, manage the user's library, read + transcripts and AI output, collaborate, administer organizations, storage, billing and developer apps, + or run Cap without the web dashboard. Requires the `cap` command from Cap Desktop (https://cap.so). --- # Cap CLI -`cap` is a command-line screen recorder built to be driven by agents. Every command takes `--json` for -machine-readable output on stdout; stderr stays human-readable; failures exit non-zero and, in `--json` -mode, print a final object/event with an `error` field. - -**`cap guide --json` is the authoritative contract** (output convention, env vars, exit codes, and every -command's output mode + event tags). Prefer it over guessing, and `cap --help` for flags. - -## First: check it's available +Start by reading the installed CLI's authoritative contract: ```sh -cap doctor --json +cap guide --json ``` -- If `cap` is not found, it isn't installed. Tell the user to install Cap Desktop from - https://cap.so/download, then enable the CLI (Settings → Command Line), or run - `curl -fsSL https://cap.so/install-cli.sh | sh` (Windows: `irm https://cap.so/install-cli.ps1 | iex`). -- Read `captureReady` and `permissions.screenRecording` from the output. On macOS, recording fails until - Screen Recording permission is granted in System Settings. +Use `--json` for machine-readable output. Treat stdout as authoritative, stderr as diagnostics, exit code +`1` as a runtime failure, and exit code `2` as invalid usage. Do not guess output schemas that are available +from `cap guide --json` or `cap --help`. -## Discover capture targets +## Cap library -```sh -cap targets --json # screens, windows, cameras, mics -``` +Authorize once with `cap auth login --json`. Login defaults to the least-privileged `creator` profile; request +`admin` or `full` only when the task requires it. Use `cap auth status --json` to verify the credential with the +server. For headless use, `CAP_AGENT_TOKEN` overrides the OS-stored credential. Existing Cap Desktop and +`CAP_API_KEY` credentials remain supported. -Use a screen's `id` (or a window's `id`) for `--screen`/`--window`, a camera's `deviceId` for `--camera`, -and a mic's `name` for `--mic`. +Use `cap caps list --json` for discovery, `cap caps get --json` for lightweight metadata and +capabilities, and `cap caps context --json` for title, AI title, summary, chapters, transcript, +comments, reactions, views, sharing, permissions, and processing state. -## Record (background lifecycle — the usual agent pattern) +`cap caps wait --for transcript|ai|all --json` only observes existing processing. Reads and +waits must never be described as starting transcription, AI generation, or any paid work. -When you don't know the duration in advance: start detached, do the work, then stop. +Stream large content to disk: ```sh -cap record start --screen --json --detach # -> {"type":"started","recordingId","pid","path"} -# ... perform the actions to capture ... -cap record stop --id --json # -> {"type":"stopped","path","recordingMetaExists":true} -cap record status --json # list active sessions +cap caps transcript --format text --output transcript.txt --json +cap caps download --output recording.mp4 --json ``` -A `stopped` event is only a complete recording when `recordingMetaExists` is `true`. +For `PASSWORD_REQUIRED`, ask the user to run `cap caps unlock ` in a secure terminal. Never request a +password in an agent prompt, argument, JSON object, log, or MCP tool call. -Fixed-length alternative (no detach): `cap record start --screen --duration 10 --json`. +Before any mutation, show the exact proposed action and obtain explicit user confirmation. This includes +posting or deleting content, changing metadata or visibility, starting paid processing, replacing transcripts, +moving or deleting Caps, changing membership or storage, creating checkout links, and rotating credentials. +Pass `--yes` or MCP `confirmed=true` only after that confirmation. Reads and waits do not require confirmation. -## Screenshot +## Dashboard-independent management -```sh -cap screenshot --screen --path shot.png --json # -> {"path","width","height"} -``` +Use `cap account`, `cap organizations`, `cap library`, `cap notifications`, `cap analytics`, `cap developers`, +and `cap jobs` for account, organization, sharing, storage, billing, analytics, developer, and asynchronous +operation workflows. Inspect each command's current arguments with `--help`; do not reconstruct them from this +skill. -## Export and share +Organization invites deliver email by default. Use `cap organizations invite add ... --no-email` only when the +user explicitly wants a link-only invite, and report the returned `emailDelivery` state. -```sh -cap project validate --json # confirm the recording is complete -cap export --output out.mp4 --json # render (here --format means container: mp4|gif|mov) -cap upload out.mp4 --json # -> {"type":"uploaded","id","link"} -``` +Some secure or local-file actions are intentionally CLI-only: Cap passwords, S3 credentials, profile and +organization images, and newly issued developer credentials. Never ask the user to paste those values into +chat or an MCP call. Ask them to run the exact secure terminal command. S3 accepts hidden prompts or +`--credentials-stdin`, never credential arguments. -`cap upload` authenticates automatically by reusing the user's Cap Desktop login — check with -`cap auth status --json`. If it reports `authenticated:false`, tell the user to sign into Cap Desktop, -or set `CAP_API_KEY` (a Cap auth key from Settings) for headless use. `cap upload --export ---json` exports then uploads in one step. +Stripe checkout, the billing portal, Google Drive authorization, and `cap account referrals` return a URL and +may open a browser. These are focused provider handoffs, not a dependency on the Cap dashboard. The agent may +continue after the user completes the handoff and should re-read state to verify the result. -## Automations +Use `cap jobs wait --json` for durable operations. Do not infer success from an accepted or queued +response. Warn that `cap account sign-out-all` revokes every device and agent session, including the current CLI +credential. -Automations are `trigger -> (conditions) -> actions` rules authored in Cap Desktop (Settings → -Automations) and shared with the CLI. After `cap screenshot`, a `cap record` finish, and `cap upload`, -the CLI evaluates the matching rules and runs their actions (save to a folder, run a command, send a -webhook, export, etc.). +Use `cap caps import loom --organization --yes --json` for Loom imports. Organization managers may +add `--owner-email` and `--space` to perform CSV-style migrations one durable, idempotent row at a time. Wait for +the returned operation before treating the import as complete. -```sh -cap automations list --json # the rules the CLI will honor -``` +## Local MCP + +`cap mcp serve` is a stdio server; its stdout is protocol-only. Prefer MCP for structured reads, safe writes, +resource links, and browser-handoff URLs. Fall back to the CLI for secure-input and local-file actions. + +Install MCP or this skill for one explicitly selected agent with +`cap agents install --target --component skill|mcp|all --dry-run`. Review the preview, then rerun +interactively or with `--yes`. Never install into every detected agent automatically. -Desktop-only actions (copy to clipboard, OCR, notifications, open editor) are skipped on the CLI; all -others run. `cap doctor --json` reports the configured rule count under `automations`. +## Recording and sharing -## Conventions to rely on +Check readiness with `cap doctor --json` and discover devices with `cap targets --json`. For an unknown +duration, use the detached lifecycle: + +```sh +cap record start --screen --detach --json +cap record stop --id --json +cap project validate --json +cap export --output out.mp4 --json +cap upload out.mp4 --json +``` -- Add `--json` to any command; it overrides each command's `--format`. -- Detect failure with a single check: the process exits non-zero and the JSON carries an `error` field. -- `record` and `export` stream newline-delimited JSON (NDJSON) events; everything else returns one object. -- `doctor`, `project validate`, and `recordings list` are reports — branch on their fields - (`ok`/`captureReady`, `valid`), not just the exit code. +A stopped recording is complete only when `recordingMetaExists` is `true`. From 0bc3838dbe25f9934f7f1cceb12ad17e1c00ac15 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:29 +0100 Subject: [PATCH 45/58] test(cli): add agent API CLI tests --- apps/cli/tests/cli.rs | 588 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 586 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/cli.rs b/apps/cli/tests/cli.rs index c54529288a8..14d3b52fbed 100644 --- a/apps/cli/tests/cli.rs +++ b/apps/cli/tests/cli.rs @@ -1,6 +1,15 @@ use std::{ + io::{BufRead, BufReader, Read, Write}, + net::TcpListener, path::Path, - process::{Command, Output}, + process::{Command, Output, Stdio}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::Duration, }; use serde_json::Value; @@ -50,6 +59,16 @@ fn help_succeeds_and_lists_commands() { "upload", "update", "screenshot", + "caps", + "account", + "organizations", + "library", + "notifications", + "analytics", + "developers", + "jobs", + "mcp", + "agents", ] { assert!(text.contains(command), "help missing '{command}':\n{text}"); } @@ -89,6 +108,16 @@ fn subcommand_help_succeeds() { "upload", "update", "screenshot", + "caps", + "account", + "organizations", + "library", + "notifications", + "analytics", + "developers", + "jobs", + "mcp", + "agents", ] { let output = run(&[command, "--help"]); assert!( @@ -554,10 +583,489 @@ fn guide_json_is_parseable_and_self_describing() { assert!(output.status.success(), "stderr: {}", stderr(&output)); let json = parse_json(&output); assert_eq!(json["binary"], "cap"); - assert!(json["schemaVersion"].is_number()); + assert_eq!(json["schemaVersion"], 3); assert!(json["commands"].is_array()); assert!(json["env"].is_array()); assert!(json["outputConvention"].is_object()); + let commands = serde_json::to_string(&json["commands"]).unwrap(); + assert!(commands.contains("caps unlock")); + assert!(commands.contains("caps comments|reactions|update|sharing")); + assert!(commands.contains("caps import loom")); + assert!(commands.contains("account get|update|image|referrals|sign-out-all")); + assert!(commands.contains( + "organizations create|update|icon|shareable-icon|settings|invite|member|domain|delete" + )); + assert!(commands.contains("organizations billing get|checkout|portal")); + assert!(commands.contains("organizations storage list|s3|provider|google-drive")); + assert!(commands.contains("developers list|get|create|update|delete")); +} + +#[test] +fn dashboard_parity_help_exposes_secure_command_trees() { + for args in [ + &["account", "image", "--help"][..], + &["account", "referrals", "--help"][..], + &["organizations", "billing", "--help"][..], + &["organizations", "storage", "--help"][..], + &["organizations", "storage", "s3", "--help"][..], + &["organizations", "storage", "google-drive", "--help"][..], + &["organizations", "invite", "add", "--help"][..], + &["developers", "credits", "--help"][..], + &["developers", "videos", "--help"][..], + &["developers", "transactions", "--help"][..], + &["library", "folders", "public-page", "--help"][..], + &["library", "folders", "logo", "--help"][..], + &["library", "spaces", "public-page", "--help"][..], + &["library", "spaces", "logo", "--help"][..], + &["caps", "import", "loom", "--help"][..], + ] { + let output = run(args); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + } + + let output = run(&["organizations", "storage", "s3", "set", "--help"]); + let help = stdout(&output).to_lowercase(); + assert!(!help.contains("access-key")); + assert!(!help.contains("secret-access")); + + let output = run(&["organizations", "invite", "add", "--help"]); + assert!(stdout(&output).contains("--no-email")); +} + +#[test] +fn non_interactive_s3_requires_secure_credential_input() { + let output = cap() + .args([ + "organizations", + "storage", + "s3", + "set", + "org_synthetic", + "--bucket", + "synthetic", + "--yes", + "--format", + "json", + ]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_test_token") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!( + stderr(&output).contains("--credentials-stdin or --reuse-credentials"), + "stderr: {}", + stderr(&output) + ); +} + +#[test] +fn caps_help_lists_the_agent_interface() { + let output = run(&["caps", "--help"]); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let text = stdout(&output); + for command in [ + "list", + "get", + "context", + "status", + "wait", + "transcript", + "download", + "process", + "transcript-replace", + "duplicate", + "delete", + "password", + "unlock", + "comments", + "reactions", + "update", + "sharing", + ] { + assert!( + text.contains(command), + "caps help missing {command}:\n{text}" + ); + } + for args in [ + &["caps", "comments", "--help"][..], + &["caps", "reactions", "--help"][..], + &["caps", "sharing", "--help"][..], + ] { + let output = run(args); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + } +} + +#[test] +fn agent_content_mutations_require_explicit_confirmation() { + for args in [ + vec!["caps", "comments", "add", "cap_synthetic", "hello"], + vec![ + "caps", + "comments", + "reply", + "cap_synthetic", + "comment_synthetic", + "hello", + ], + vec!["caps", "reactions", "add", "cap_synthetic", "thumbs-up"], + vec![ + "caps", + "update", + "cap_synthetic", + "--title", + "Synthetic title", + ], + vec!["caps", "sharing", "set", "cap_synthetic", "--public"], + ] { + let output = cap() + .args(args) + .arg("--format") + .arg("json") + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_test_token") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(parse_json(&output)["code"], "INVALID_REQUEST"); + } +} + +#[test] +fn sharing_requires_one_visibility_flag() { + let output = run(&["caps", "sharing", "set", "cap_synthetic"]); + assert_eq!(output.status.code(), Some(2)); + let output = run(&[ + "caps", + "sharing", + "set", + "cap_synthetic", + "--public", + "--private", + ]); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn agent_installer_dry_run_is_machine_readable() { + let output = run(&[ + "agents", + "install", + "--target", + "codex", + "--component", + "all", + "--dry-run", + "--yes", + "--format", + "json", + ]); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json = parse_json(&output); + assert_eq!(json["target"], "codex"); + assert_eq!(json["dryRun"], true); + assert_eq!(json["applied"], false); + assert_eq!(json["changes"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn new_commands_emit_json_errors_for_local_format_flag() { + let caps_output = cap() + .args(["caps", "get", "!", "--format", "json"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_test_token") + .output() + .unwrap(); + assert_eq!(caps_output.status.code(), Some(1)); + assert_eq!(parse_json(&caps_output)["code"], "INVALID_REQUEST"); + + let auth_output = run(&["auth", "login", "--timeout", "0", "--format", "json"]); + assert_eq!(auth_output.status.code(), Some(1)); + assert!(parse_json(&auth_output)["error"].is_string()); + + let home = tempfile::tempdir().unwrap(); + let installer_output = cap() + .args([ + "agents", + "install", + "--target", + "codex", + "--component", + "skill", + "--format", + "json", + ]) + .env("HOME", home.path()) + .env("CODEX_HOME", home.path().join(".codex")) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(installer_output.status.code(), Some(1)); + assert!(parse_json(&installer_output)["error"].is_string()); +} + +#[test] +fn mcp_stdout_is_protocol_only_and_exposes_expected_tools() { + let mut command = cap(); + command + .args(["mcp", "serve"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_test_token") + .env("CAP_SERVER_URL", "http://127.0.0.1:9") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("failed to start MCP server"); + let stdout = child.stdout.take().unwrap(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line).is_err() { + break; + } + } + }); + let mut stdin = child.stdin.take().unwrap(); + writeln!( + stdin, + "{}", + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "cap-test", "version": "1" } + } + }) + ) + .unwrap(); + stdin.flush().unwrap(); + let initialize = receiver.recv_timeout(Duration::from_secs(10)); + writeln!( + stdin, + "{}", + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + ) + .unwrap(); + writeln!( + stdin, + "{}", + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }) + ) + .unwrap(); + stdin.flush().unwrap(); + let tools = receiver.recv_timeout(Duration::from_secs(10)); + let _ = child.kill(); + let _ = child.wait(); + + let initialize: Value = serde_json::from_str(&initialize.unwrap().unwrap()).unwrap(); + assert_eq!(initialize["id"], 1); + assert_eq!(initialize["result"]["serverInfo"]["name"], "cap"); + let tools: Value = serde_json::from_str(&tools.unwrap().unwrap()).unwrap(); + assert_eq!(tools["id"], 2); + let tool_list = tools["result"]["tools"].as_array().unwrap(); + let tool = |name: &str| { + tool_list + .iter() + .find(|tool| tool["name"] == name) + .unwrap_or_else(|| panic!("missing MCP tool {name}")) + }; + assert_eq!(tool("caps_list")["annotations"]["idempotentHint"], true); + assert_eq!( + tool("caps_import_loom")["annotations"]["idempotentHint"], + false + ); + for tool in tool_list { + if tool["annotations"]["readOnlyHint"] == false { + assert_eq!( + tool["annotations"]["idempotentHint"], false, + "mutating MCP tool {} must not claim cross-invocation idempotency", + tool["name"] + ); + } + } + let serialized = serde_json::to_string(tool_list).unwrap(); + for name in [ + "caps_list", + "caps_get", + "caps_context", + "caps_wait", + "caps_import_loom", + "caps_comment", + "caps_reply", + "caps_react", + "caps_update_title", + "caps_set_visibility", + "account_referrals_open", + "organizations_list", + "organization_create", + "organization_billing_checkout", + "organization_billing_portal", + "organization_storage_provider_set", + "organization_google_drive_connect", + "organization_google_drive_folders", + "organization_google_drive_location_set", + "organization_google_drive_disconnect", + "organization_update", + "organization_settings_update", + "organization_invite_add", + "organization_member_role", + "organization_member_seat", + "organization_delete", + "organization_domain_set", + "organization_domain_remove", + "organization_domain_verify", + "collection_public_page_update", + "developer_apps_list", + "developer_videos_list", + "developer_transactions_list", + "developer_video_delete", + "developer_app_update", + "developer_domain_add", + "developer_auto_top_up_update", + "developer_credits_checkout", + ] { + assert!(serialized.contains(name), "missing MCP tool {name}"); + } + assert!(!serialized.to_lowercase().contains("password")); + assert!(!serialized.contains("access_key_id")); + assert!(!serialized.contains("secret_access_key")); + assert!(!serialized.contains("image_data")); +} + +#[test] +fn mcp_cancellation_stops_wait_polling() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let server_url = format!("http://{}", listener.local_addr().unwrap()); + let request_count = Arc::new(AtomicUsize::new(0)); + let server_request_count = request_count.clone(); + thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request); + server_request_count.fetch_add(1, Ordering::SeqCst); + let body = r#"{"transcript":{"status":"processing"},"ai":{"status":"processing"}}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + stream.flush().unwrap(); + } + }); + + let mut command = cap(); + command + .args(["mcp", "serve"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_test_token") + .env("CAP_SERVER_URL", server_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("failed to start MCP server"); + let stdout = child.stdout.take().unwrap(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line).is_err() { + break; + } + } + }); + let mut stdin = child.stdin.take().unwrap(); + for message in [ + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "cap-test", "version": "1" } + } + }), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }), + serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "caps_wait", + "arguments": { "cap": "cap_synthetic", "timeout_seconds": 60 } + } + }), + ] { + writeln!(stdin, "{message}").unwrap(); + } + stdin.flush().unwrap(); + let initialize: Value = serde_json::from_str( + &receiver + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(initialize["id"], 1); + for _ in 0..50 { + if request_count.load(Ordering::SeqCst) > 0 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + assert_eq!(request_count.load(Ordering::SeqCst), 1); + for message in [ + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": 3, "reason": "test" } + }), + serde_json::json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/list", + "params": {} + }), + ] { + writeln!(stdin, "{message}").unwrap(); + } + stdin.flush().unwrap(); + let mut list_response = None; + for _ in 0..3 { + let response: Value = serde_json::from_str( + &receiver + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(), + ) + .unwrap(); + if response["id"] == 4 { + list_response = Some(response); + break; + } + } + thread::sleep(Duration::from_millis(750)); + let _ = child.kill(); + let _ = child.wait(); + assert!(list_response.is_some()); + assert_eq!(request_count.load(Ordering::SeqCst), 1); } #[test] @@ -644,6 +1152,82 @@ fn auth_status_json_reports_source() { assert!(json["server"].is_string()); } +#[test] +fn auth_status_recognizes_agent_environment_credentials() { + let output = cap() + .args(["auth", "status", "--json"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_agent_status") + .env("CAP_SERVER_URL", "http://127.0.0.1:9876") + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json = parse_json(&output); + assert_eq!(json["authenticated"], false); + assert_eq!(json["credentialPresent"], true); + assert_eq!(json["serverVerified"], false); + assert_eq!(json["verificationStatus"], "unavailable"); + assert_eq!(json["source"], "env"); + assert_eq!(json["server"], "http://127.0.0.1:9876"); + assert!(!stdout(&output).contains("cap_cli_synthetic_agent_status")); +} + +#[test] +fn auth_status_verifies_agent_credentials_with_the_server() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let server_url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let length = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..length]); + assert!(request.starts_with("GET /api/v1/auth/status HTTP/1.1")); + assert!(request.contains("authorization: Bearer cap_cli_synthetic_agent_status")); + let body = r#"{"authenticated":true,"tokenKind":"agent","expiresAt":"2027-01-01T00:00:00.000Z","scopes":["caps:read"],"requestId":"request-test"}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let output = cap() + .args(["auth", "status", "--json"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_agent_status") + .env("CAP_SERVER_URL", server_url) + .output() + .unwrap(); + server.join().unwrap(); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json = parse_json(&output); + assert_eq!(json["authenticated"], true); + assert_eq!(json["credentialPresent"], true); + assert_eq!(json["serverVerified"], true); + assert_eq!(json["verificationStatus"], "verified"); + assert_eq!(json["expiresAt"], "2027-01-01T00:00:00.000Z"); + assert_eq!(json["scopes"], serde_json::json!(["caps:read"])); + assert!(!stdout(&output).contains("cap_cli_synthetic_agent_status")); +} + +#[test] +fn agent_credentials_reject_insecure_remote_servers() { + let output = cap() + .args(["caps", "list", "--json"]) + .env("CAP_AGENT_TOKEN", "cap_cli_synthetic_agent_status") + .env("CAP_SERVER_URL", "http://example.com") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(stderr(&output).contains("require HTTPS")); +} + +#[test] +fn auth_login_defaults_to_the_creator_profile() { + let output = run(&["auth", "login", "--help"]); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains("[default: creator]")); +} + fn write_single_segment_meta(project: &Path) { std::fs::create_dir_all(project).unwrap(); let meta = serde_json::json!({ From ecfd2d308b0916b2249d1d5345f238cd1a3621c0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:55:30 +0100 Subject: [PATCH 46/58] chore: update Cargo.lock for CLI dependencies --- Cargo.lock | 633 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 458 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6115027bade..8f993d94a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,7 +30,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-core", "futures-sink", @@ -52,7 +52,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "bytestring", "derive_more 2.0.1", @@ -191,6 +191,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -251,7 +262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -273,7 +284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.9.4", + "bitflags 2.13.1", "cc", "jni 0.22.4", "libc", @@ -376,6 +387,17 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework 3.7.0", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -518,7 +540,7 @@ dependencies = [ "polling", "rustix 1.1.2", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -576,7 +598,7 @@ dependencies = [ "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -840,7 +862,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -863,7 +885,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -881,7 +903,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -902,7 +924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -943,11 +965,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -971,6 +993,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -1104,7 +1135,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -1129,7 +1160,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -1162,6 +1193,7 @@ dependencies = [ name = "cap" version = "0.1.0" dependencies = [ + "base64 0.22.1", "cap-automation", "cap-camera", "cap-cli-install", @@ -1182,19 +1214,26 @@ dependencies = [ "futures", "image 0.25.8", "kameo", + "keyring", "libc", + "open", "relative-path", "reqwest 0.12.24", + "rmcp", + "rpassword", "scap-screencapturekit", "scap-targets", "serde", "serde_json", + "sha2", "softbuffer", "tempfile", "tokio", "tokio-util", + "toml_edit 0.23.10+spec-1.0.0", "tracing", "tracing-subscriber", + "url", "uuid", "windows 0.60.0", "winit", @@ -2038,6 +2077,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.37" @@ -2134,7 +2182,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -2152,6 +2200,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82bc2f84c0baaa09299da3a03864491549685912c1e338a54211e00589dc1e4c" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -2258,7 +2316,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "cocoa-foundation", "core-foundation 0.10.1", @@ -2274,7 +2332,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", @@ -2310,7 +2368,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -2475,7 +2533,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2488,7 +2546,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2512,7 +2570,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -2588,7 +2646,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "fontdb 0.16.2", "log", "rangemap", @@ -2825,8 +2883,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -2843,13 +2911,37 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.106", ] @@ -2937,7 +3029,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.106", @@ -3031,6 +3123,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -3090,7 +3183,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -3105,7 +3198,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "libc", "objc2 0.6.2", @@ -3217,7 +3310,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytemuck", "drm-ffi", "drm-fourcc", @@ -3395,7 +3488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -3524,7 +3617,7 @@ name = "ffmpeg-next" version = "7.1.0" source = "git+https://github.com/CapSoftware/rust-ffmpeg?rev=49db1fede112#49db1fede1123029670728bd6e3fcbb07b0f5716" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "ffmpeg-sys-next", "libc", ] @@ -4141,7 +4234,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -4268,7 +4361,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "gpu-alloc-types", ] @@ -4278,7 +4371,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -4299,7 +4392,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -4310,7 +4403,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -4473,6 +4566,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hmac-sha256" version = "1.1.14" @@ -4992,13 +5103,23 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inquire" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "crossterm", "dyn-clone", "fuzzy-matcher", @@ -5047,7 +5168,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -5179,7 +5300,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.16", "walkdir", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5306,7 +5427,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -5320,6 +5441,27 @@ dependencies = [ "indexmap 2.11.4", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -5477,7 +5619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5503,7 +5645,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", "redox_syscall 0.5.17", ] @@ -5514,7 +5656,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cc", "cookie-factory", "libc", @@ -5822,7 +5964,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -5837,7 +5979,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -5868,7 +6010,7 @@ version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "debugid", "num-derive", "num-traits", @@ -5883,7 +6025,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "byteorder", "cfg-if", "crash-context", @@ -6058,7 +6200,7 @@ checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg_aliases 0.2.1", "codespan-reporting", "half", @@ -6096,7 +6238,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -6143,7 +6285,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "jni-sys 0.3.0", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -6157,7 +6299,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "jni-sys 0.3.0", "log", "ndk-sys 0.6.0+11769913", @@ -6211,7 +6353,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -6223,23 +6365,10 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset", ] [[package]] @@ -6305,6 +6434,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -6350,6 +6493,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -6463,7 +6616,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -6479,7 +6632,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "libc", "objc2 0.6.2", @@ -6498,7 +6651,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e085a2e16c61dadbad7a808fc9d5b5f8472b1b825b53d529c9f64ccac78e722" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "dispatch2", "objc2 0.6.2", @@ -6528,7 +6681,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -6541,7 +6694,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-foundation 0.3.1", ] @@ -6575,7 +6728,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", ] @@ -6585,7 +6738,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6597,7 +6750,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-foundation 0.3.1", ] @@ -6608,7 +6761,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.2", ] @@ -6619,7 +6772,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.2", "objc2-core-foundation", @@ -6666,7 +6819,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0b7afa6822e2fa20dfc88d10186b2432bf8560b5ed73ec9d31efd78277bc878" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.2", "objc2-core-audio", @@ -6681,7 +6834,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1989c3e76c7e978cab0ba9e6f4961cd00ed14ca21121444cc26877403bfb6303" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-core-foundation", "objc2-core-graphics", @@ -6709,7 +6862,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -6722,7 +6875,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "libc", "objc2 0.6.2", @@ -6745,7 +6898,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-core-foundation", ] @@ -6778,7 +6931,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6790,7 +6943,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", @@ -6802,7 +6955,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6815,7 +6968,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-foundation 0.3.1", ] @@ -6826,7 +6979,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-core-foundation", ] @@ -6847,7 +7000,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -6868,7 +7021,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "objc2 0.6.2", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -6891,7 +7044,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -6904,7 +7057,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "objc2 0.6.2", "objc2-app-kit 0.3.1", @@ -6991,7 +7144,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -7009,14 +7162,13 @@ dependencies = [ [[package]] name = "open" -version = "5.3.2" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -7025,7 +7177,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -7344,10 +7496,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pathdiff" +name = "pastey" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pbr" @@ -7580,7 +7732,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", "libspa", "libspa-sys", @@ -7643,7 +7795,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -7661,7 +7813,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -7790,7 +7942,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.6", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -7838,7 +7990,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "hex", ] @@ -8301,7 +8453,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -8588,6 +8740,41 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.106", +] + [[package]] name = "rodio" version = "0.19.0" @@ -8608,6 +8795,27 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -8665,7 +8873,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -8678,11 +8886,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -8772,7 +8980,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytemuck", "libm", "smallvec", @@ -8789,7 +8997,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytemuck", "core_maths", "log", @@ -8908,7 +9116,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -8919,7 +9127,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -8944,8 +9152,10 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.0.4", "serde", "serde_json", ] @@ -8962,6 +9172,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -9033,24 +9255,56 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.16", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -9219,7 +9473,7 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "sentry-backtrace", "sentry-core", "tracing-core", @@ -9245,9 +9499,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -9267,18 +9521,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -9387,7 +9641,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.106", @@ -9589,7 +9843,7 @@ version = "0.86.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e372258f52414e04de007326fa497581617c9fa872a3225dca5e42212723c426" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "lazy_static", "skia-bindings", ] @@ -9642,7 +9896,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -9821,7 +10075,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -10132,7 +10386,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -10187,7 +10441,7 @@ dependencies = [ name = "tao" version = "0.34.3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.6.1", "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -10378,7 +10632,7 @@ name = "tauri-nspanel" version = "2.0.1" source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "cocoa", "core-foundation 0.10.1", @@ -10726,7 +10980,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "log", "serde", "serde_json", @@ -10885,7 +11139,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -11244,7 +11498,7 @@ dependencies = [ "indexmap 2.11.4", "serde_core", "serde_spanned 1.1.0", - "toml_datetime 0.7.2", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.13", @@ -11276,9 +11530,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -11318,13 +11572,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.11.4", - "toml_datetime 0.7.2", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 0.7.13", ] @@ -11397,7 +11652,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.3.1", @@ -12167,7 +12422,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "rustix 1.1.2", "wayland-backend", "wayland-scanner", @@ -12179,7 +12434,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -12201,7 +12456,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -12213,7 +12468,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -12226,7 +12481,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -12393,7 +12648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" dependencies = [ "arrayvec", - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg_aliases 0.2.1", "document-features", "hashbrown 0.15.5", @@ -12423,7 +12678,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg_aliases 0.2.1", "document-features", "hashbrown 0.15.5", @@ -12480,7 +12735,7 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "bytemuck", "cfg-if", @@ -12523,7 +12778,7 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -12595,7 +12850,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -12848,9 +13103,22 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] [[package]] name = "windows-numerics" @@ -12985,11 +13253,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -13070,7 +13338,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -13271,7 +13539,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop", @@ -13337,6 +13605,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -13389,7 +13660,7 @@ version = "0.1.0" dependencies = [ "arrayvec", "axum", - "bitflags 2.9.4", + "bitflags 2.13.1", "bytemuck", "chrono", "clang-sys", @@ -13567,7 +13838,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -13646,9 +13917,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.11.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-executor", @@ -13664,25 +13935,38 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.2", "serde", "serde_repr", "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", - "winnow 0.7.13", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", @@ -13695,13 +13979,12 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "static_assertions", - "winnow 0.7.13", + "winnow 1.0.3", "zvariant", ] @@ -13835,24 +14118,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.7.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.13", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", @@ -13863,13 +14146,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.106", - "winnow 0.7.13", + "winnow 1.0.3", ] From c8061de287bd4d1ae2cc37ce315dde66030762cb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:09:33 +0100 Subject: [PATCH 47/58] fix: track agent credential usage --- apps/web/__tests__/unit/agent-auth.test.ts | 18 +++++++++++- packages/web-backend/src/AgentAuth.ts | 34 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/agent-auth.test.ts b/apps/web/__tests__/unit/agent-auth.test.ts index 9421241c55b..402a2364b16 100644 --- a/apps/web/__tests__/unit/agent-auth.test.ts +++ b/apps/web/__tests__/unit/agent-auth.test.ts @@ -1,5 +1,8 @@ import { createHash } from "node:crypto"; -import { isLegacyAgentKeySource } from "@cap/web-backend"; +import { + isLegacyAgentKeySource, + shouldRefreshAgentLastUsedAt, +} from "@cap/web-backend"; import { describe, expect, it } from "vitest"; import { buildAgentCallbackUrl, @@ -155,3 +158,16 @@ describe("legacy agent credentials", () => { expect(isLegacyAgentKeySource("extension")).toBe(false); }); }); + +describe("agent credential usage tracking", () => { + it("refreshes missing and stale usage timestamps", () => { + const now = new Date("2026-07-19T00:00:00.000Z"); + expect(shouldRefreshAgentLastUsedAt(null, now)).toBe(true); + expect( + shouldRefreshAgentLastUsedAt(new Date("2026-07-18T23:55:00.000Z"), now), + ).toBe(true); + expect( + shouldRefreshAgentLastUsedAt(new Date("2026-07-18T23:55:00.001Z"), now), + ).toBe(false); + }); +}); diff --git a/packages/web-backend/src/AgentAuth.ts b/packages/web-backend/src/AgentAuth.ts index bb152e0b75f..4a3c79a29bc 100644 --- a/packages/web-backend/src/AgentAuth.ts +++ b/packages/web-backend/src/AgentAuth.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import * as Db from "@cap/database/schema"; import { Agent } from "@cap/web-domain"; import { HttpServerRequest } from "@effect/platform"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull, lte, or } from "drizzle-orm"; import { Effect, Layer, Schema } from "effect"; import { Database } from "./Database.ts"; @@ -45,6 +45,15 @@ const parseBearerToken = (authorization: string | undefined) => { const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); +const agentLastUsedRefreshMs = 5 * 60 * 1000; + +export const shouldRefreshAgentLastUsedAt = ( + lastUsedAt: Date | null, + now: Date, +) => + lastUsedAt === null || + now.getTime() - lastUsedAt.getTime() >= agentLastUsedRefreshMs; + const agentScopes = new Set([ "caps:read", "caps:comment", @@ -118,6 +127,7 @@ export const AgentHttpAuthMiddlewareLive = Layer.effect( scopes: Db.agentApiKeys.scopes, expiresAt: Db.agentApiKeys.expiresAt, revokedAt: Db.agentApiKeys.revokedAt, + lastUsedAt: Db.agentApiKeys.lastUsedAt, id: Db.users.id, email: Db.users.email, activeOrganizationId: Db.users.activeOrganizationId, @@ -136,6 +146,28 @@ export const AgentHttpAuthMiddlewareLive = Layer.effect( if (!isAgentReadAccessEnabled(row.email)) { return yield* temporarilyUnavailable(); } + const now = new Date(); + if (shouldRefreshAgentLastUsedAt(row.lastUsedAt, now)) { + yield* database + .use((db) => + db + .update(Db.agentApiKeys) + .set({ lastUsedAt: now }) + .where( + and( + eq(Db.agentApiKeys.id, row.tokenId), + or( + isNull(Db.agentApiKeys.lastUsedAt), + lte( + Db.agentApiKeys.lastUsedAt, + new Date(now.getTime() - agentLastUsedRefreshMs), + ), + ), + ), + ), + ) + .pipe(Effect.catchAll(() => Effect.void)); + } return Agent.AgentPrincipal.of({ id: row.id, email: row.email, From ea1dc7d7cd939a343d76e9214d4d201c832decb5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:09:39 +0100 Subject: [PATCH 48/58] fix: respond to invalid auth callbacks --- apps/cli/src/agent_auth.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/cli/src/agent_auth.rs b/apps/cli/src/agent_auth.rs index b425f8ceaaa..fbc6e8752d5 100644 --- a/apps/cli/src/agent_auth.rs +++ b/apps/cli/src/agent_auth.rs @@ -184,6 +184,15 @@ async fn wait_for_callback( .next() .ok_or_else(|| "Browser approval was not valid HTTP".to_string())?; if method != "GET" { + let body = "Authorization request used an invalid HTTP method.\n"; + let response = format!( + "HTTP/1.1 405 Method Not Allowed\r\nAllow: GET\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|error| error.to_string())?; return Err("Browser approval used an invalid HTTP method".to_string()); } let target = request_line @@ -480,4 +489,29 @@ mod tests { .unwrap(); assert_eq!(callback.await.unwrap().unwrap(), "one_time_code"); } + + #[tokio::test] + async fn callback_rejects_non_get_requests_with_http_response() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let callback = tokio::spawn(wait_for_callback( + listener, + "expected_state", + Duration::from_secs(2), + )); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all(b"POST /callback HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .await + .unwrap(); + let mut response = String::new(); + client.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed\r\n")); + assert!(response.contains("\r\nAllow: GET\r\n")); + assert!(response.ends_with("Authorization request used an invalid HTTP method.\n")); + assert_eq!( + callback.await.unwrap().unwrap_err(), + "Browser approval used an invalid HTTP method" + ); + } } From f598d75879a7c8f7545ef503d6baed78ce878416 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:09:45 +0100 Subject: [PATCH 49/58] fix: escape agent library search patterns --- apps/web/__tests__/unit/agent-api.test.ts | 5 +++++ apps/web/app/api/v1/[...route]/route.ts | 6 ++++-- apps/web/lib/agent-api.ts | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/agent-api.test.ts b/apps/web/__tests__/unit/agent-api.test.ts index ce3bebf23ed..11292f6a747 100644 --- a/apps/web/__tests__/unit/agent-api.test.ts +++ b/apps/web/__tests__/unit/agent-api.test.ts @@ -7,6 +7,7 @@ import { agentTranscriptRevision, decodeAgentCursor, encodeAgentCursor, + escapeAgentLikePattern, parseAgentDate, parseAgentLimit, parseAgentVtt, @@ -49,6 +50,10 @@ describe("agent API normalization", () => { expect(parseAgentDate("2026-02-30T12:00:00Z")).toBeUndefined(); }); + it("escapes SQL LIKE metacharacters in literal searches", () => { + expect(escapeAgentLikePattern("100%_ready!")).toBe("100!%!_ready!!"); + }); + it("normalizes VTT into millisecond cues and text", () => { const cues = parseAgentVtt( "WEBVTT\r\n\r\n1\r\n00:00:01.250 --> 00:00:03.500 align:start\r\nHello agent\r\n\r\n2\r\n00:00:04,000 --> 00:00:05,125\r\nSecond line\r\n", diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 21e169e4396..5fe445c603a 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -55,7 +55,6 @@ import { gt, inArray, isNull, - like, lt, ne, or, @@ -77,6 +76,7 @@ import { agentTranscriptRevision, decodeAgentCursor, encodeAgentCursor, + escapeAgentLikePattern, normalizeAgentMetadata, parseAgentDate, parseAgentLimit, @@ -745,7 +745,9 @@ const listCaps = Effect.fn("Agent.listCaps")(function* ( ? ne(Db.videos.ownerId, principal.id) : undefined, isNull(Db.organizations.tombstoneAt), - search ? like(Db.videos.name, `%${search}%`) : undefined, + search + ? sql`${Db.videos.name} LIKE ${`%${escapeAgentLikePattern(search)}%`} ESCAPE '!'` + : undefined, updatedAfter ? gt(Db.videos.updatedAt, updatedAfter) : undefined, cursorFilter, ]; diff --git a/apps/web/lib/agent-api.ts b/apps/web/lib/agent-api.ts index 39d1ae4fa83..1eace33ec7f 100644 --- a/apps/web/lib/agent-api.ts +++ b/apps/web/lib/agent-api.ts @@ -39,6 +39,9 @@ export const parseAgentLimit = (value: string | undefined) => { return Math.min(parsed, 100); }; +export const escapeAgentLikePattern = (value: string) => + value.replace(/[!%_]/g, (match) => `!${match}`); + const parseAgentUtcDate = (value: string) => { const match = value.match( /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/, From d66351e83550dbb174d2994e34eb658f6fbd298d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:25:27 +0100 Subject: [PATCH 50/58] fix: clean up revoked agent credentials --- .../web/__tests__/e2e/agent-local-e2e.test.ts | 22 ++++++++++++++----- apps/web/lib/agent-api-cleanup.ts | 11 +++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/web/__tests__/e2e/agent-local-e2e.test.ts b/apps/web/__tests__/e2e/agent-local-e2e.test.ts index 6681b84611b..216f213240a 100644 --- a/apps/web/__tests__/e2e/agent-local-e2e.test.ts +++ b/apps/web/__tests__/e2e/agent-local-e2e.test.ts @@ -1677,10 +1677,12 @@ agentE2e("Cap agent local Docker E2E", () => { ); await connection.execute( `INSERT INTO agent_api_keys - (id, userId, tokenHash, name, scopes, expiresAt) + (id, userId, tokenHash, name, scopes, expiresAt, revokedAt) VALUES - ('key_e2e_old', ?, ?, 'Expired cleanup key', ?, DATE_SUB(NOW(), INTERVAL 31 DAY)), - ('key_e2e_live2', ?, ?, 'Live cleanup key', ?, DATE_ADD(NOW(), INTERVAL 1 DAY))`, + ('key_e2e_old', ?, ?, 'Expired cleanup key', ?, DATE_SUB(NOW(), INTERVAL 31 DAY), NULL), + ('key_e2e_live2', ?, ?, 'Live cleanup key', ?, DATE_ADD(NOW(), INTERVAL 1 DAY), NULL), + ('key_e2e_rvold', ?, ?, 'Old revoked cleanup key', ?, DATE_ADD(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 31 DAY)), + ('key_e2e_rvnew', ?, ?, 'Recent revoked cleanup key', ?, DATE_ADD(NOW(), INTERVAL 1 DAY), NOW())`, [ userId, "5".repeat(64), @@ -1688,6 +1690,12 @@ agentE2e("Cap agent local Docker E2E", () => { userId, "6".repeat(64), JSON.stringify(["caps:read"]), + userId, + "7".repeat(64), + JSON.stringify(["caps:read"]), + userId, + "8".repeat(64), + JSON.stringify(["caps:read"]), ], ); process.env.CRON_SECRET = "synthetic-agent-cleanup-secret"; @@ -1711,22 +1719,26 @@ agentE2e("Cap agent local Docker E2E", () => { const deleted = asObject(cleanupBody.deleted); expect(Number(deleted.authorizationCodes)).toBeGreaterThanOrEqual(1); expect(Number(deleted.idempotencyRecords)).toBeGreaterThanOrEqual(1); - expect(Number(deleted.accessTokens)).toBeGreaterThanOrEqual(1); + expect(Number(deleted.accessTokens)).toBeGreaterThanOrEqual(2); const [remaining] = await connection.execute( `SELECT (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_live') AS liveCodes, (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_live') AS liveIdempotency, (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_live2') AS liveKeys, + (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_rvnew') AS recentRevokedKeys, (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_expired') AS expiredCodes, (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_expired') AS expiredIdempotency, - (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_old') AS expiredKeys`, + (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_old') AS expiredKeys, + (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_rvold') AS oldRevokedKeys`, ); expect(Number(remaining[0]?.liveCodes)).toBe(1); expect(Number(remaining[0]?.liveIdempotency)).toBe(1); expect(Number(remaining[0]?.liveKeys)).toBe(1); + expect(Number(remaining[0]?.recentRevokedKeys)).toBe(1); expect(Number(remaining[0]?.expiredCodes)).toBe(0); expect(Number(remaining[0]?.expiredIdempotency)).toBe(0); expect(Number(remaining[0]?.expiredKeys)).toBe(0); + expect(Number(remaining[0]?.oldRevokedKeys)).toBe(0); }); it("revokes every session only as the final destructive account action", async () => { diff --git a/apps/web/lib/agent-api-cleanup.ts b/apps/web/lib/agent-api-cleanup.ts index 37f705d5a7f..907526e0a71 100644 --- a/apps/web/lib/agent-api-cleanup.ts +++ b/apps/web/lib/agent-api-cleanup.ts @@ -6,7 +6,7 @@ import { agentApiIdempotency, agentApiKeys, } from "@cap/database/schema"; -import { lt } from "drizzle-orm"; +import { lt, or } from "drizzle-orm"; const affectedRows = (result: unknown) => Array.isArray(result) @@ -40,7 +40,7 @@ export const cleanupExpiredAgentApiRecords = async (input?: { input?.keyRetentionMs ?? 30 * 24 * 60 * 60 * 1_000, 0, ); - const expiredKeyCutoff = new Date(now.getTime() - keyRetentionMs); + const keyRetentionCutoff = new Date(now.getTime() - keyRetentionMs); const database = db(); const authorizationCodes = await deleteInBatches( @@ -65,7 +65,12 @@ export const cleanupExpiredAgentApiRecords = async (input?: { () => database .delete(agentApiKeys) - .where(lt(agentApiKeys.expiresAt, expiredKeyCutoff)) + .where( + or( + lt(agentApiKeys.expiresAt, keyRetentionCutoff), + lt(agentApiKeys.revokedAt, keyRetentionCutoff), + ), + ) .limit(batchSize), batchSize, maxBatches, From 1e79ae92ac6b9d247476b2b78b5873ce62c48b21 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:25:31 +0100 Subject: [PATCH 51/58] fix: preserve idempotency across token rotation --- .../unit/agent-write-contract.test.ts | 16 ++++++++++++++ apps/web/lib/agent-write.ts | 21 ++++++++----------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/web/__tests__/unit/agent-write-contract.test.ts b/apps/web/__tests__/unit/agent-write-contract.test.ts index c40c702e51a..c26eff691de 100644 --- a/apps/web/__tests__/unit/agent-write-contract.test.ts +++ b/apps/web/__tests__/unit/agent-write-contract.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + agentMutationRequestHash, isAgentIdempotencyKey, isAgentWriteAccessEnabled, } from "@/lib/agent-write"; @@ -35,6 +36,21 @@ describe("agent write safety", () => { expect(isAgentIdempotencyKey("contains a secret")).toBe(false); }); + it("keeps mutation identity independent of credential rotation", () => { + const request = { videoId: "cap_test", title: "Updated" }; + const requestHash = agentMutationRequestHash("update_cap", request); + expect(agentMutationRequestHash("update_cap", request)).toBe(requestHash); + expect( + agentMutationRequestHash("update_cap", { + ...request, + title: "Different", + }), + ).not.toBe(requestHash); + expect(agentMutationRequestHash("delete_cap", request)).not.toBe( + requestHash, + ); + }); + it("completes the mutation and idempotency record in one transaction", () => { const source = readFileSync( join(process.cwd(), "lib/agent-write.ts"), diff --git a/apps/web/lib/agent-write.ts b/apps/web/lib/agent-write.ts index 01d628db12e..4b10ee689e3 100644 --- a/apps/web/lib/agent-write.ts +++ b/apps/web/lib/agent-write.ts @@ -44,6 +44,9 @@ const getAffectedRows = (result: unknown) => { const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +export const agentMutationRequestHash = (operation: string, request: unknown) => + hash(JSON.stringify({ operation, request })); + export const isAgentIdempotencyKey = (value: string | undefined) => typeof value === "string" && value.length >= 8 && @@ -201,12 +204,9 @@ export const runAgentMutation = (input: { ); } const database = yield* Database; - const requestHash = hash( - JSON.stringify({ - tokenId: input.principal.tokenId, - operation: input.operation, - request: input.request, - }), + const requestHash = agentMutationRequestHash( + input.operation, + input.request, ); const result = yield* database.use((db) => db.transaction(async (tx) => { @@ -294,12 +294,9 @@ export const runAgentExternalMutation = (input: { ); } const database = yield* Database; - const requestHash = hash( - JSON.stringify({ - tokenId: input.principal.tokenId, - operation: input.operation, - request: input.request, - }), + const requestHash = agentMutationRequestHash( + input.operation, + input.request, ); const acquired = yield* database.use((db) => db.transaction(async (tx) => { From 9b26d82f94fb04da2fb8676799000c92f984e735 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:41:10 +0100 Subject: [PATCH 52/58] fix: allow CLI auth on self-hosted deployments --- apps/web/__tests__/unit/proxy-self-hosted.test.ts | 10 ++++++++++ apps/web/proxy.ts | 1 + 2 files changed, 11 insertions(+) create mode 100644 apps/web/__tests__/unit/proxy-self-hosted.test.ts diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts new file mode 100644 index 00000000000..ad20bffdb38 --- /dev/null +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -0,0 +1,10 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("self-hosted proxy routes", () => { + it("allows browser-based CLI authorization pages", () => { + const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8"); + expect(source).toContain('path.startsWith("/cli/")'); + }); +}); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index a14c79ea47a..34ef676204b 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -45,6 +45,7 @@ export async function proxy(request: NextRequest) { !( path.startsWith("/s/") || path.startsWith("/c/") || + path.startsWith("/cli/") || path.startsWith("/middleware") || path.startsWith("/dashboard") || path.startsWith("/onboarding") || From dc8f876324be94940bee81786a44fe16a2255caf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:41:15 +0100 Subject: [PATCH 53/58] fix: expire completed agent operations --- .../web/__tests__/e2e/agent-local-e2e.test.ts | 22 +++++++++++++++ apps/web/lib/agent-api-cleanup.ts | 27 +++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/e2e/agent-local-e2e.test.ts b/apps/web/__tests__/e2e/agent-local-e2e.test.ts index 216f213240a..f3348256428 100644 --- a/apps/web/__tests__/e2e/agent-local-e2e.test.ts +++ b/apps/web/__tests__/e2e/agent-local-e2e.test.ts @@ -1675,6 +1675,21 @@ agentE2e("Cap agent local Docker E2E", () => { "4".repeat(64), ], ); + await connection.execute( + `INSERT INTO agent_api_operations + (id, userId, kind, resourceId, state, payload, updatedAt, completedAt) + VALUES + ('op_e2e_old', ?, 'duplicate_cap', ?, 'succeeded', ?, DATE_SUB(NOW(), INTERVAL 31 DAY), DATE_SUB(NOW(), INTERVAL 31 DAY)), + ('op_e2e_run', ?, 'duplicate_cap', ?, 'running', ?, DATE_SUB(NOW(), INTERVAL 31 DAY), NULL)`, + [ + userId, + videoId, + JSON.stringify({ videoId }), + userId, + videoId, + JSON.stringify({ videoId }), + ], + ); await connection.execute( `INSERT INTO agent_api_keys (id, userId, tokenHash, name, scopes, expiresAt, revokedAt) @@ -1719,24 +1734,31 @@ agentE2e("Cap agent local Docker E2E", () => { const deleted = asObject(cleanupBody.deleted); expect(Number(deleted.authorizationCodes)).toBeGreaterThanOrEqual(1); expect(Number(deleted.idempotencyRecords)).toBeGreaterThanOrEqual(1); + expect(Number(deleted.operations)).toBeGreaterThanOrEqual(1); expect(Number(deleted.accessTokens)).toBeGreaterThanOrEqual(2); const [remaining] = await connection.execute( `SELECT (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_live') AS liveCodes, (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_live') AS liveIdempotency, + (SELECT COUNT(*) FROM agent_api_operations WHERE id = 'op_e2e_ready') AS recentOperations, + (SELECT COUNT(*) FROM agent_api_operations WHERE id = 'op_e2e_run') AS runningOperations, (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_live2') AS liveKeys, (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_rvnew') AS recentRevokedKeys, (SELECT COUNT(*) FROM agent_api_authorization_codes WHERE id = 'cod_e2e_expired') AS expiredCodes, (SELECT COUNT(*) FROM agent_api_idempotency WHERE id = 'ide_e2e_expired') AS expiredIdempotency, + (SELECT COUNT(*) FROM agent_api_operations WHERE id = 'op_e2e_old') AS oldOperations, (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_old') AS expiredKeys, (SELECT COUNT(*) FROM agent_api_keys WHERE id = 'key_e2e_rvold') AS oldRevokedKeys`, ); expect(Number(remaining[0]?.liveCodes)).toBe(1); expect(Number(remaining[0]?.liveIdempotency)).toBe(1); + expect(Number(remaining[0]?.recentOperations)).toBe(1); + expect(Number(remaining[0]?.runningOperations)).toBe(1); expect(Number(remaining[0]?.liveKeys)).toBe(1); expect(Number(remaining[0]?.recentRevokedKeys)).toBe(1); expect(Number(remaining[0]?.expiredCodes)).toBe(0); expect(Number(remaining[0]?.expiredIdempotency)).toBe(0); + expect(Number(remaining[0]?.oldOperations)).toBe(0); expect(Number(remaining[0]?.expiredKeys)).toBe(0); expect(Number(remaining[0]?.oldRevokedKeys)).toBe(0); }); diff --git a/apps/web/lib/agent-api-cleanup.ts b/apps/web/lib/agent-api-cleanup.ts index 907526e0a71..2928b0c2474 100644 --- a/apps/web/lib/agent-api-cleanup.ts +++ b/apps/web/lib/agent-api-cleanup.ts @@ -5,8 +5,9 @@ import { agentApiAuthorizationCodes, agentApiIdempotency, agentApiKeys, + agentApiOperations, } from "@cap/database/schema"; -import { lt, or } from "drizzle-orm"; +import { and, inArray, lt, or } from "drizzle-orm"; const affectedRows = (result: unknown) => Array.isArray(result) @@ -32,6 +33,7 @@ export const cleanupExpiredAgentApiRecords = async (input?: { batchSize?: number; maxBatches?: number; keyRetentionMs?: number; + operationRetentionMs?: number; }) => { const now = input?.now ?? new Date(); const batchSize = Math.min(Math.max(input?.batchSize ?? 1_000, 1), 5_000); @@ -40,7 +42,14 @@ export const cleanupExpiredAgentApiRecords = async (input?: { input?.keyRetentionMs ?? 30 * 24 * 60 * 60 * 1_000, 0, ); + const operationRetentionMs = Math.max( + input?.operationRetentionMs ?? 30 * 24 * 60 * 60 * 1_000, + 0, + ); const keyRetentionCutoff = new Date(now.getTime() - keyRetentionMs); + const operationRetentionCutoff = new Date( + now.getTime() - operationRetentionMs, + ); const database = db(); const authorizationCodes = await deleteInBatches( @@ -61,6 +70,20 @@ export const cleanupExpiredAgentApiRecords = async (input?: { batchSize, maxBatches, ); + const operations = await deleteInBatches( + () => + database + .delete(agentApiOperations) + .where( + and( + inArray(agentApiOperations.state, ["succeeded", "failed"]), + lt(agentApiOperations.updatedAt, operationRetentionCutoff), + ), + ) + .limit(batchSize), + batchSize, + maxBatches, + ); const accessTokens = await deleteInBatches( () => database @@ -76,5 +99,5 @@ export const cleanupExpiredAgentApiRecords = async (input?: { maxBatches, ); - return { authorizationCodes, idempotencyRecords, accessTokens }; + return { authorizationCodes, idempotencyRecords, operations, accessTokens }; }; From 0e6dbe5ea29bfb578394e390d14eaa1128da58fd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:46:58 +0100 Subject: [PATCH 54/58] fix: allow tombstoned organization name reuse --- .../__tests__/unit/agent-api-contract.test.ts | 20 +++++++++++++++++++ apps/web/app/api/v1/[...route]/route.ts | 14 +++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/unit/agent-api-contract.test.ts b/apps/web/__tests__/unit/agent-api-contract.test.ts index 68e9b184d3d..0829d93ee0b 100644 --- a/apps/web/__tests__/unit/agent-api-contract.test.ts +++ b/apps/web/__tests__/unit/agent-api-contract.test.ts @@ -231,6 +231,26 @@ describe("agent API contract", () => { expect(memberSource).toContain("Db.spaceMembers"); }); + it("ignores tombstones during organization creation", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const organizationStart = source.indexOf('.handle("createOrganization"'); + const organizationSource = source.slice( + organizationStart, + source.indexOf('.handle("updateOrganization"', organizationStart), + ); + + expect(organizationSource).toContain( + "isNull(Db.organizations.tombstoneAt)", + ); + expect(organizationSource).toMatch( + /deterministicAgentId\(\s*"organization",\s*principal\.id,\s*idempotencyKey,/, + ); + expect(organizationSource).toContain("organization.tombstoneAt !== null"); + }); + it("stores only developer key identifiers in idempotency responses", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 5fe445c603a..5c429ccc93a 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -5635,17 +5635,18 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( "Organization name is invalid", ); } + const idempotencyKey = yield* requestIdempotencyKey; const organizationId = Organisation.OrganisationId.make( deterministicAgentId( "organization", principal.id, - name.toLowerCase(), + idempotencyKey, ), ); return yield* runAgentMutation({ principal, operation: "create_organization", - idempotencyKey: yield* requestIdempotencyKey, + idempotencyKey, request: { name }, requestId, decodeReplay: decodeMutationResponse, @@ -5654,7 +5655,10 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .select({ id: Db.organizations.id }) .from(Db.organizations) .where( - sql`LOWER(${Db.organizations.name}) = ${name.toLowerCase()}`, + and( + sql`LOWER(${Db.organizations.name}) = ${name.toLowerCase()}`, + isNull(Db.organizations.tombstoneAt), + ), ) .limit(1); if (existingName && existingName.id !== organizationId) { @@ -5674,6 +5678,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .select({ ownerId: Db.organizations.ownerId, name: Db.organizations.name, + tombstoneAt: Db.organizations.tombstoneAt, }) .from(Db.organizations) .where(eq(Db.organizations.id, organizationId)) @@ -5682,7 +5687,8 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( if ( !organization || organization.ownerId !== principal.id || - organization.name !== name + organization.name !== name || + organization.tombstoneAt !== null ) { return { state: "conflict" }; } From 60d30ea619365ff2d40c5b8ec1f837b14b96cc24 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:47:17 +0100 Subject: [PATCH 55/58] perf: bound folder deletion traversal --- .../web/__tests__/unit/agent-api-contract.test.ts | 15 +++++++++++++++ apps/web/app/api/v1/[...route]/route.ts | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/agent-api-contract.test.ts b/apps/web/__tests__/unit/agent-api-contract.test.ts index 0829d93ee0b..0c474a874b2 100644 --- a/apps/web/__tests__/unit/agent-api-contract.test.ts +++ b/apps/web/__tests__/unit/agent-api-contract.test.ts @@ -397,6 +397,21 @@ describe("agent API contract", () => { expect(decoded.videos[0]?.id).toBe("video_synthetic"); }); + it("bounds folder deletion traversal", () => { + const source = readFileSync( + join(process.cwd(), "app/api/v1/[...route]/route.ts"), + "utf8", + ); + const deleteFolderStart = source.indexOf('.handle("deleteFolder"'); + const deleteFolderSource = source.slice( + deleteFolderStart, + source.indexOf('.handle("createSpace"', deleteFolderStart), + ); + + expect(deleteFolderSource).toContain("const seenFolderIds = new Set"); + expect(deleteFolderSource).not.toContain("folderIds.includes"); + }); + it("validates and atomically merges public collection customization", () => { const source = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 5c429ccc93a..1076f00ea6c 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -7958,6 +7958,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .for("update"); if (!folder) return { state: "not_found" }; const folderIds = [folder.id]; + const seenFolderIds = new Set(folderIds); for (let offset = 0; offset < folderIds.length; offset += 100) { if (folderIds.length > 1_000) return { state: "conflict" }; const parents = folderIds.slice(offset, offset + 100); @@ -7965,11 +7966,11 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .select({ id: Db.folders.id }) .from(Db.folders) .where(inArray(Db.folders.parentId, parents)); - folderIds.push( - ...children - .map((child) => child.id) - .filter((id) => !folderIds.includes(id)), - ); + for (const child of children) { + if (seenFolderIds.has(child.id)) continue; + seenFolderIds.add(child.id); + folderIds.push(child.id); + } } if (folder.spaceId === null) { await tx From 13b8c565b2d294e12504b5ec2c0f4285f24b3aaa Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:47:21 +0100 Subject: [PATCH 56/58] fix: validate developer app logo URLs --- apps/web/__tests__/unit/agent-api.test.ts | 8 ++++++++ apps/web/app/api/v1/[...route]/route.ts | 11 ++++++++--- apps/web/lib/agent-api.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/agent-api.test.ts b/apps/web/__tests__/unit/agent-api.test.ts index 11292f6a747..c0980ebd2e2 100644 --- a/apps/web/__tests__/unit/agent-api.test.ts +++ b/apps/web/__tests__/unit/agent-api.test.ts @@ -8,6 +8,7 @@ import { decodeAgentCursor, encodeAgentCursor, escapeAgentLikePattern, + isAgentHttpUrl, parseAgentDate, parseAgentLimit, parseAgentVtt, @@ -54,6 +55,13 @@ describe("agent API normalization", () => { expect(escapeAgentLikePattern("100%_ready!")).toBe("100!%!_ready!!"); }); + it("accepts only absolute HTTP developer logo URLs", () => { + expect(isAgentHttpUrl("https://cdn.example.com/logo.png")).toBe(true); + expect(isAgentHttpUrl("http://localhost:3000/logo.png")).toBe(true); + expect(isAgentHttpUrl("javascript:alert(1)")).toBe(false); + expect(isAgentHttpUrl("/logo.png")).toBe(false); + }); + it("normalizes VTT into millisecond cues and text", () => { const cues = parseAgentVtt( "WEBVTT\r\n\r\n1\r\n00:00:01.250 --> 00:00:03.500 align:start\r\nHello agent\r\n\r\n2\r\n00:00:04,000 --> 00:00:05,125\r\nSecond line\r\n", diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 1076f00ea6c..3a99a586ede 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -77,6 +77,7 @@ import { decodeAgentCursor, encodeAgentCursor, escapeAgentLikePattern, + isAgentHttpUrl, normalizeAgentMetadata, parseAgentDate, parseAgentLimit, @@ -3733,10 +3734,14 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( ); } const name = payload.name?.trim(); + const logoUrl = + payload.logoUrl === null ? null : payload.logoUrl?.trim(); if ( (name !== undefined && (name.length === 0 || name.length > 255)) || - (payload.logoUrl?.length ?? 0) > 1024 + (logoUrl !== undefined && + logoUrl !== null && + (logoUrl.length > 1024 || !isAgentHttpUrl(logoUrl))) ) { return yield* badRequest( requestId, @@ -3747,7 +3752,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( principal, operation: "update_developer_app", idempotencyKey: yield* requestIdempotencyKey, - request: { path, ...payload, name }, + request: { path, ...payload, name, logoUrl }, requestId, decodeReplay: decodeMutationResponse, execute: async (tx) => { @@ -3770,7 +3775,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .set({ name, environment: payload.environment, - logoUrl: payload.logoUrl, + logoUrl, updatedAt: now, }) .where(eq(Db.developerApps.id, path.appId)); diff --git a/apps/web/lib/agent-api.ts b/apps/web/lib/agent-api.ts index 1eace33ec7f..f9dd23a57c3 100644 --- a/apps/web/lib/agent-api.ts +++ b/apps/web/lib/agent-api.ts @@ -42,6 +42,18 @@ export const parseAgentLimit = (value: string | undefined) => { export const escapeAgentLikePattern = (value: string) => value.replace(/[!%_]/g, (match) => `!${match}`); +export const isAgentHttpUrl = (value: string) => { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname.length > 0 + ); + } catch { + return false; + } +}; + const parseAgentUtcDate = (value: string) => { const match = value.match( /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/, From 3c155034918bb47c747b3f4f86853cd758c2737a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:31:21 +0100 Subject: [PATCH 57/58] fix: gate unix-only credential import --- apps/cli/src/credentials.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/credentials.rs b/apps/cli/src/credentials.rs index 0b046d8a9e0..634fe3e8a8a 100644 --- a/apps/cli/src/credentials.rs +++ b/apps/cli/src/credentials.rs @@ -10,7 +10,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{OutputFormat, atomic, write_json}; +#[cfg(unix)] +use crate::atomic; +use crate::{OutputFormat, write_json}; pub const DEFAULT_SERVER: &str = "https://cap.so"; const AGENT_KEYRING_SERVICE: &str = "so.cap.cli"; From 0793d9ae6f92d56d174381f05f93e8952e253ee9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:22:36 +0100 Subject: [PATCH 58/58] fix: address agent API code scanning alerts --- .../web/__tests__/e2e/agent-local-e2e.test.ts | 3 ++- apps/web/__tests__/unit/agent-api.test.ts | 11 +++++++++ apps/web/lib/agent-api.ts | 24 +++++++++++++++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/e2e/agent-local-e2e.test.ts b/apps/web/__tests__/e2e/agent-local-e2e.test.ts index f3348256428..4398ca3c628 100644 --- a/apps/web/__tests__/e2e/agent-local-e2e.test.ts +++ b/apps/web/__tests__/e2e/agent-local-e2e.test.ts @@ -451,8 +451,9 @@ const startApiServer = async () => { outgoing.writeHead(response.status, Object.fromEntries(response.headers)); outgoing.end(responseBody); } catch (error) { + console.error("Agent E2E request failed", error); outgoing.writeHead(500, { "Content-Type": "text/plain" }); - outgoing.end(error instanceof Error ? error.message : String(error)); + outgoing.end("Internal Server Error"); } }); await new Promise((resolveListen, reject) => { diff --git a/apps/web/__tests__/unit/agent-api.test.ts b/apps/web/__tests__/unit/agent-api.test.ts index c0980ebd2e2..dfb13d91a29 100644 --- a/apps/web/__tests__/unit/agent-api.test.ts +++ b/apps/web/__tests__/unit/agent-api.test.ts @@ -77,6 +77,17 @@ describe("agent API normalization", () => { expect(agentTranscriptRevision(rendered)).toMatch(/^[a-f0-9]{64}$/); }); + it("removes complete and unterminated VTT tags", () => { + const cues = parseAgentVtt( + "WEBVTT\n\n1\n00:00:00.000 --> 00:00:01.000\nSafe text\n\n2\n00:00:01.000 --> 00:00:02.000\nBefore { for (const bytes of [10_000, 100_000, 500_000]) { const vtt = makeSyntheticVtt(bytes); diff --git a/apps/web/lib/agent-api.ts b/apps/web/lib/agent-api.ts index f9dd23a57c3..080d72489d8 100644 --- a/apps/web/lib/agent-api.ts +++ b/apps/web/lib/agent-api.ts @@ -110,11 +110,27 @@ const parseVttTimestamp = (value: string) => { return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds; }; +const stripVttCueTags = (value: string) => { + const text: string[] = []; + let insideTag = false; + + for (const character of value) { + if (character === "<") { + insideTag = true; + continue; + } + if (insideTag) { + if (character === ">") insideTag = false; + continue; + } + text.push(character); + } + + return text.join(""); +}; + const normalizeCueText = (lines: string[]) => - lines - .join("\n") - .replace(/<[^>]+>/g, "") - .trim(); + stripVttCueTags(lines.join("\n")).trim(); export const parseAgentVtt = ( vtt: string,