From 2af85228c0fbec71cbbdcddffbf89597451466d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:15:00 -0700 Subject: [PATCH 1/6] feat(credentials): add v2 OAuth connection APIs --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 353 +++++++++++++++++- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- .../api/auth/oauth2/authorize/route.test.ts | 85 ++++- .../app/api/auth/oauth2/authorize/route.ts | 180 +++++---- .../v2/credential-connections/route.test.ts | 150 ++++++++ .../api/v2/credential-connections/route.ts | 32 ++ .../api/v2/credential-providers/route.test.ts | 124 ++++++ .../app/api/v2/credential-providers/route.ts | 27 ++ .../oauth/credential-connected/page.test.tsx | 36 ++ .../app/oauth/credential-connected/page.tsx | 50 +++ .../lib/api/contracts/oauth-connections.ts | 46 ++- .../v2/__tests__/list-pagination.test.ts | 1 + apps/sim/lib/api/contracts/v2/credentials.ts | 129 +++++++ .../lib/api/contracts/v2/openapi/resources.ts | 77 +++- apps/sim/lib/core/application/forbidden.ts | 4 + .../application/connection-target.test.ts | 150 ++++++++ .../application/connection-target.ts | 92 +++++ .../create-credential-connection.test.ts | 128 +++++++ .../create-credential-connection.ts | 53 +++ .../launch-credential-connection.test.ts | 93 +++++ .../launch-credential-connection.ts | 52 +++ .../list-credential-providers.test.ts | 68 ++++ .../application/list-credential-providers.ts | 29 ++ .../lib/credentials/application/operations.ts | 18 + .../application/provider-catalog.test.ts | 176 +++++++++ .../application/provider-catalog.ts | 113 ++++++ apps/sim/lib/credentials/connect-draft.ts | 83 ++-- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 34 files changed, 2244 insertions(+), 125 deletions(-) create mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-connections/route.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.ts create mode 100644 apps/sim/app/oauth/credential-connected/page.test.tsx create mode 100644 apps/sim/app/oauth/credential-connected/page.tsx create mode 100644 apps/sim/lib/credentials/application/connection-target.test.ts create mode 100644 apps/sim/lib/credentials/application/connection-target.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.test.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.test.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 5b513c1c03d..eada35ce0ed 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -477,7 +477,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 3e605aa00e1..6a29b4542a5 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2245,7 +2245,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e0d93148561..d7e63995376 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2206,7 +2206,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index eaf97c33c96..89b62afab4c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -596,7 +596,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 6b7fe8bd62b..69ae6f3206e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "List OAuth and service-account connections without secret material." + "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." }, { "name": "Secrets", @@ -1705,6 +1705,140 @@ } } }, + "/api/v2/credential-providers": { + "get": { + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace used to evaluate OAuth availability and integration policy.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace used to evaluate OAuth availability and integration policy." + } + } + ], + "responses": { + "200": { + "description": "OAuth provider catalog with caller-specific availability.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCredentialProvidersResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/credential-connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionBody" + } + } + } + }, + "responses": { + "200": { + "description": "A short-lived browser authorization URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -2264,7 +2398,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -4075,6 +4209,221 @@ } ] }, + "V2CredentialProvider": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable OAuth service identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "OAuth service display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "OAuth service description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can start the OAuth flow in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false, + "title": "Credential Provider", + "description": "An OAuth service that may be connected to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credential providers response", + "description": "OAuth providers and their authorization-server options.", + "examples": [ + { + "data": [ + { + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + } + ], + "nextCursor": null + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact provider ID returned by the credential-provider catalog." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } + }, + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index a4aff379376..c33fe610f69 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3976,7 +3976,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index c93a2b3352e..19a0b5f6fcd 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2292,7 +2292,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 54f49a5e29f..938e0f3310a 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -4,8 +4,10 @@ import { createMockRequest, dbChainMockFns, + queueTableRows, resetDbChainMock, resetEnvMock, + schemaMock, setEnv, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,11 +17,13 @@ const { mockOAuth2LinkAccount, mockCheckWorkspaceAccess, mockGetCredentialActorContext, + mockLaunchCredentialConnection, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockOAuth2LinkAccount: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockGetCredentialActorContext: vi.fn(), + mockLaunchCredentialConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ @@ -35,6 +39,13 @@ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, })) +vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ + launchCredentialConnection: { + operation: { id: 'credentials.connections.launch' }, + execute: mockLaunchCredentialConnection, + }, +})) + vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), // Real implementation: a credential id matches its service's OAuth id, an @@ -99,7 +110,16 @@ describe('OAuth2 authorize route', () => { GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret', }) - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockGetSession.mockResolvedValue({ + user: { id: USER_ID }, + session: { id: 'session-1' }, + }) + queueTableRows(schemaMock.user, [{ name: 'Test User' }]) + dbChainMockFns.onConflictDoUpdate.mockImplementation(() => ({ + returning: vi + .fn() + .mockResolvedValue([{ id: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z') }]), + })) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -114,6 +134,69 @@ describe('OAuth2 authorize route', () => { }) }) + describe('draft-bound connection', () => { + it('resolves the exact user-bound draft before starting OAuth', async () => { + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: "Test User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + const request = authorizeRequest({ draftId: 'draft-1' }) + + const response = await GET(request) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockLaunchCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: USER_ID, sessionId: 'session-1' }, + input: { draftId: 'draft-1' }, + request, + }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + providerId: 'google-email', + callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected`, + errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, + }, + }) + ) + }) + + it('hands custom providers to their authenticated browser flow', async () => { + setEnv({ TRELLO_API_KEY: 'trello-key' }) + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'trello', + displayName: "Test User's Trello", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + + const response = await GET(authorizeRequest({ draftId: 'draft-1' })) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected` + ) + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) + describe('plain connect (no credentialId)', () => { it('creates a draft with credentialId null and redirects to the provider', async () => { const response = await GET( diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..49d8d8c7fcf 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -4,9 +4,11 @@ import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -30,92 +32,136 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query + const { draftId } = parsed.data.query + let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query - const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` - - try { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, + let fromConnectionDraft = false + if (draftId) { + try { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const { draft } = await launchCredentialConnection.execute({ + principal: { + kind: 'session', + userId, + sessionId, + }, + input: { draftId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) } + } + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } + + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + try { let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { + if (!fromConnectionDraft) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canWrite) { + logger.warn('Workspace write access denied for OAuth2 authorize', { userId, workspaceId, providerId, - credentialId, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, + if (credentialId) { + // Trello and Shopify authorize through their own custom flows that bypass + // this endpoint, so a reconnect draft written here would linger unconsumed + // and could later be picked up by their token-store callbacks, silently + // rebinding the credential. Mirror the copilot tool and reject reconnect. + if (providerId === 'trello' || providerId === 'shopify') { + logger.warn('Reconnect not supported for custom-flow provider', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect( + `${baseUrl}/workspace?error=credential_reconnect_unsupported` + ) + } + + // Reconnect: the OAuth callback will rebind this credential to the fresh + // account, so require the same credential-admin access as the draft POST + // route — workspace write alone must not be enough to swap someone's tokens. + const actor = await getCredentialActorContext(credentialId, userId, { + workspaceAccess: access, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + if ( + !actor.credential || + actor.credential.workspaceId !== workspaceId || + actor.credential.type !== 'oauth' || + !actor.isAdmin + ) { + logger.warn('Credential admin access denied for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (actor.credential.providerId !== providerId) { + logger.warn('Provider mismatch for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + credentialProviderId: actor.credential.providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + reconnectDisplayName = actor.credential.displayName } - reconnectDisplayName = actor.credential.displayName } requireConfiguredOAuthClient(providerId) - // Create the draft before initiating the link so it is guaranteed to exist - // (and freshly clocked) when the OAuth callback's `account.create.after` - // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + if (!draftId) { + await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) + } + + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + return NextResponse.redirect(authorizeUrl) + } const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL, + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts new file mode 100644 index 00000000000..569eb61dd72 --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credential-connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credential-connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('rejects requests that provide both connection targets', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('requires a display name for a new connection', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects a display name when reconnecting an existing credential', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('returns the short-lived browser URL', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + }) + + it('conceals inaccessible workspaces as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credential-connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.ts @@ -0,0 +1,32 @@ +import { v2CreateCredentialConnectionContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialConnectionErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialConnectionContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createConnection, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: createCredentialConnection, + present: ({ authorizationUrl, expiresAt }) => ({ + data: { + authorizationUrl, + expiresAt: expiresAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts new file mode 100644 index 00000000000..27de3904a9a --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credential-providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +describe('GET /api/v2/credential-providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + providers: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + }) + }) + + it('returns the full provider catalog in one page', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + expect(await response.json()).toEqual({ + data: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + nextCursor: null, + }) + }) + + it('rejects query parameters it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('conceals a workspace-key scope mismatch as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credential-providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.ts @@ -0,0 +1,27 @@ +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialProviderErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialProvidersContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.listProviders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialProviderErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCredentialProviders, + present: ({ providers }) => ({ data: providers, nextCursor: null }), +}) diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..c0583aede98 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,50 @@ +import { ChipLink } from '@sim/emcn' +import { CircleAlert, CircleCheck } from '@sim/emcn/icons' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+
+ {connected ? ( + + ) : ( + + )} +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..3e184df791c 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -262,12 +262,46 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + .optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index b6034e3af3b..4ce9499de71 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -83,6 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credential-providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..60d537b2754 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -4,6 +4,7 @@ import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, @@ -44,6 +45,43 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output +export const v2CredentialProviderAuthorizationOptionSchema = z.object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), +}) +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderSchema = z + .object({ + serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), + name: z.string().min(1).max(255).describe('OAuth service display name.'), + description: z.string().min(1).max(1000).describe('OAuth service description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can start the OAuth flow in the current deployment.'), + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth service that may be connected to a workspace.', + }) +export type V2CredentialProvider = z.output + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -80,3 +118,94 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate OAuth availability and integration policy.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credential-providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact provider ID returned by the credential-provider catalog.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credential-connections', + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..377a1cb7442 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,8 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +170,24 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -804,6 +826,56 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'OAuth provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace used to evaluate provider availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth providers and their authorization-server options.', + [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + resourceOperation('Credentials', { + operationId: 'createCredentialConnection', + summary: 'Create Credential Connection', + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1025,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..8d9ed60e7c5 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can write in the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..6365988099e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,92 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, + requireAvailableCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: CredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string +}): Promise { + const { principal, context, providerId, credentialId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find((entry) => + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..b934755f0e5 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns only its browser entrypoint', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..513e2b21c85 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,53 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName: string; credentialId?: never } + | { credentialId: string; providerId?: never; displayName?: never } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + expiresAt: Date +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + }) + const displayName = input.providerId ? input.displayName : target.displayName + if (!displayName) throw new Error('Resolved credential connection target has no display name') + + const draft = await createConnectDraft({ + userId: principal.userId, + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + expiresAt: draft.expiresAt, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..c7d11d848d9 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('rejects unsupported principals before canonical workspace loading', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..b24d45ea811 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,29 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => ({ + providers: await listCredentialProviderCatalog(principal, context), + }), +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..7432c1c7430 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,10 +1,28 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const credentialOperations = { + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), } as const diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..51aece09f81 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'service-account-only', + providerId: 'service-account-only', + name: 'Service account', + description: 'Not OAuth.', + baseProvider: 'test', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: false, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..760cda016d0 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,113 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderCatalogEntry { + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices, + }) + + return oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: !['trello', 'shopify'].includes(service.providerId), + authorizationOptions, + } + }) +} + +export function requireAvailableCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): CredentialProviderCatalogEntry { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..0c455f68dff 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,13 +2,20 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') const DRAFT_TTL_MS = 15 * 60 * 1000 +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} + /** * Creates the pending credential draft at OAuth click time so custom and * generic OAuth callbacks can materialize the connected workspace credential. @@ -21,7 +28,7 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,42 +39,21 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } @@ -79,10 +65,11 @@ export async function createConnectDraft(params: { .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, @@ -100,8 +87,11 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) throw new Error('OAuth connect draft insert returned no row') logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +99,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 58393603987..5a40d039ee0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1105, - zodRoutes: 1105, + totalRoutes: 1107, + zodRoutes: 1107, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..191fb41000a 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 22], + ['apps/docs/openapi-v2-resources.json', 24], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(135) + expect(totalOperations).toBe(137) }) it('documents mixed workflow execution and resume responses', () => { From f0767d3af14cc824c8cd3b9c7db500ccb6d75083 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:28:29 -0700 Subject: [PATCH 2/6] fix(credentials): preserve active OAuth connection links --- .../sim/lib/credentials/connect-draft.test.ts | 46 +++++++++++++++++++ apps/sim/lib/credentials/connect-draft.ts | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/connect-draft.test.ts diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..a230dec4cc8 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('preserves the active draft ID when refreshing the same connection intent', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + expect(conflict?.set).toMatchObject({ + displayName: 'Work Gmail', + credentialId: null, + }) + expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 0c455f68dff..027f6ae26a2 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -87,7 +87,7 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) From e4b09dca7f047a76ede2f84719d5bbeefd6b6361 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:37:24 -0700 Subject: [PATCH 3/6] fix(credentials): bind OAuth links to connection intent --- .../api/auth/oauth2/authorize/route.test.ts | 17 +++++------ .../v2/credential-connections/route.test.ts | 30 +++++++++++++++++++ .../sim/lib/credentials/connect-draft.test.ts | 28 +++++++++++++---- apps/sim/lib/credentials/connect-draft.ts | 21 +++++++++---- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 938e0f3310a..c9bb3e8e9dd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -214,9 +214,7 @@ describe('OAuth2 authorize route', () => { }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) @@ -232,11 +230,12 @@ describe('OAuth2 authorize route', () => { ) }) - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { + it('does not overwrite a reconnect intent when refreshing a plain connect', async () => { await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) + const [{ set, setWhere }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] + expect(set).not.toHaveProperty('credentialId') + expect(setWhere).toBeDefined() }) it('rejects an OAuth client that is not configured for the deployment', async () => { @@ -283,7 +282,7 @@ describe('OAuth2 authorize route', () => { }) describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { + it('creates a reconnect draft and guards conflict refreshes by intent', async () => { mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) const response = await GET( @@ -304,9 +303,7 @@ describe('OAuth2 authorize route', () => { expect.objectContaining({ credentialId: CREDENTIAL_ID }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts index 569eb61dd72..3c69d229b94 100644 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -12,6 +12,7 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ execute: vi.fn() })) @@ -147,4 +148,33 @@ describe('POST /api/v2/credential-connections', () => { error: { code: 'NOT_FOUND', message: 'Workspace not found' }, }) }) + + it('returns a conflict when another intent already owns the active provider draft', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A different OAuth connection flow is already active for this provider', + }, + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index a230dec4cc8..4394d86067b 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -19,7 +19,7 @@ describe('createConnectDraft', () => { mockGenerateId.mockReturnValue('new-draft-id') }) - it('preserves the active draft ID when refreshing the same connection intent', async () => { + it('refreshes the expiry without changing an active connection intent', async () => { const expiresAt = new Date('2026-08-13T20:15:00.000Z') dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) @@ -34,13 +34,29 @@ describe('createConnectDraft', () => { expect.objectContaining({ id: 'new-draft-id' }) ) const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as - | { set?: Record } + | { set?: Record; setWhere?: unknown } | undefined expect(conflict?.set).not.toHaveProperty('id') - expect(conflict?.set).toMatchObject({ - displayName: 'Work Gmail', - credentialId: null, - }) + expect(conflict?.set).not.toHaveProperty('displayName') + expect(conflict?.set).not.toHaveProperty('credentialId') + expect(conflict?.setWhere).toBeDefined() expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + + it('fails fast when an active draft has a different connection intent', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'A different OAuth connection flow is already active for this provider', + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 027f6ae26a2..23a7e4ca34a 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,7 +2,8 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, gt, lt } from 'drizzle-orm' +import { and, eq, gt, isNull, lt } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' @@ -84,14 +85,22 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { expiresAt, createdAt: now }, + setWhere: and( + eq(pendingCredentialDraft.displayName, displayName), + credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + ), }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) - if (!draft) throw new Error('OAuth connect draft insert returned no row') + if (!draft) { + throw new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + } logger.info('Created OAuth connect credential draft', { userId, From e14963685475f86759cc94fa50edf6eb430a68b3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:32:38 -0700 Subject: [PATCH 4/6] feat(credentials): complete v2 credential lifecycle --- apps/docs/openapi-v2-resources.json | 722 ++++++++++++++++-- .../v2/credential-connections/route.test.ts | 180 ----- .../api/v2/credential-providers/route.test.ts | 124 --- .../credentials/[credentialId]/route.test.ts | 77 ++ .../v2/credentials/[credentialId]/route.ts | 30 + .../v2/credentials/connections/route.test.ts | 93 +++ .../connections}/route.ts | 0 .../v2/credentials/providers/route.test.ts | 114 +++ .../providers}/route.ts | 0 apps/sim/app/api/v2/credentials/route.test.ts | 128 +++- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../v2/__tests__/list-pagination.test.ts | 2 +- apps/sim/lib/api/contracts/v2/credentials.ts | 295 ++++++- .../lib/api/contracts/v2/openapi/resources.ts | 132 +++- .../__tests__/webhook-deactivation.test.ts | 39 +- .../application/connection-target.test.ts | 3 +- .../application/connection-target.ts | 22 +- .../lib/credentials/application/operations.ts | 12 + .../credentials/application/presentation.ts | 26 + .../application/provider-catalog.test.ts | 73 +- .../application/provider-catalog.ts | 259 ++++++- .../application/service-account.test.ts | 209 +++++ .../application/service-account.ts | 211 +++++ apps/sim/lib/credentials/deletion.ts | 27 + .../orchestration/credential-create.ts | 109 ++- .../lib/credentials/orchestration/index.ts | 3 + .../credential-visibility.server.ts | 23 +- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 29 files changed, 2444 insertions(+), 527 deletions(-) delete mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts delete mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/connections/route.test.ts rename apps/sim/app/api/v2/{credential-connections => credentials/connections}/route.ts (100%) create mode 100644 apps/sim/app/api/v2/credentials/providers/route.test.ts rename apps/sim/app/api/v2/{credential-providers => credentials/providers}/route.ts (100%) create mode 100644 apps/sim/lib/credentials/application/presentation.ts create mode 100644 apps/sim/lib/credentials/application/service-account.test.ts create mode 100644 apps/sim/lib/credentials/application/service-account.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 69ae6f3206e..9706338488b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." + "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." }, { "name": "Secrets", @@ -1562,7 +1562,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { @@ -1703,30 +1703,118 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, - "/api/v2/credential-providers": { + "/api/v2/credentials/providers": { "get": { "operationId": "listCredentialProviders", "summary": "List Credential Providers", - "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace used to evaluate OAuth availability and integration policy.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, - "description": "Workspace used to evaluate OAuth availability and integration policy." + "description": "Workspace used to evaluate credential-provider availability and integration policy." } } ], "responses": { "200": { - "description": "OAuth provider catalog with caller-specific availability.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1770,7 +1858,7 @@ } } }, - "/api/v2/credential-connections": { + "/api/v2/credentials/connections": { "post": { "operationId": "createCredentialConnection", "summary": "Create Credential Connection", @@ -1839,6 +1927,83 @@ } } }, + "/api/v2/credentials/{credentialId}": { + "delete": { + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "credentialId", + "in": "path", + "required": true, + "description": "Credential to disconnect.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential to disconnect." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + } + } + ], + "responses": { + "200": { + "description": "The credential was disconnected.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -4210,78 +4375,245 @@ ] }, "V2CredentialProvider": { - "type": "object", - "properties": { - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable OAuth service identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "OAuth service display name." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "OAuth service description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can start the OAuth flow in the current deployment." - }, - "supportsReconnect": { - "type": "boolean", - "description": "Whether existing credentials for this service can be reconnected." - }, - "authorizationOptions": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "object", - "properties": { - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider identifier accepted by the connection endpoint." + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable authorization-server label." - } + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." }, - "required": ["providerId", "label"], - "additionalProperties": false + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } }, - "description": "Authorization servers available for this OAuth service." + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false } - }, - "required": [ - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions" ], - "additionalProperties": false, "title": "Credential Provider", - "description": "An OAuth service that may be connected to a workspace." + "description": "An OAuth or service-account connection method available to a workspace." }, "ListCredentialProvidersResponse": { "type": "object", @@ -4308,11 +4640,12 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List credential providers response", - "description": "OAuth providers and their authorization-server options.", + "description": "OAuth and service-account connection methods.", "examples": [ { "data": [ { + "type": "oauth", "serviceId": "salesforce", "name": "Salesforce", "description": "Connect to Salesforce CRM data and operations.", @@ -4329,12 +4662,209 @@ "label": "Sandbox" } ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] } ], "nextCursor": null } ] }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, "V2CredentialConnectionAuthorization": { "type": "object", "properties": { @@ -4390,7 +4920,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Exact provider ID returned by the credential-provider catalog." + "description": "Exact OAuth provider ID returned by credential-provider discovery." }, "displayName": { "type": "string", @@ -4424,6 +4954,46 @@ "title": "Create credential connection body", "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts deleted file mode 100644 index 3c69d229b94..00000000000 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ - createCredentialConnection: { - operation: { id: 'credentials.connections.create' }, - execute: mocks.execute, - }, -})) - -import { POST } from '@/app/api/v2/credential-connections/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} - -describe('POST /api/v2/credential-connections', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - }) - }) - - it('rejects requests that provide both connection targets', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - credentialId: 'credential-1', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('requires a display name for a new connection', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('rejects a display name when reconnecting an existing credential', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - credentialId: 'credential-1', - displayName: 'Renamed Gmail', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('returns the short-lived browser URL', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - const response = await POST(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }, - request, - }) - expect(await response.json()).toEqual({ - data: { - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: '2026-08-12T20:15:00.000Z', - }, - }) - }) - - it('conceals inaccessible workspaces as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) - - it('returns a conflict when another intent already owns the active provider draft', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError( - 'conflict', - 'A different OAuth connection flow is already active for this provider' - ) - ) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ - error: { - code: 'CONFLICT', - message: 'A different OAuth connection flow is already active for this provider', - }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts deleted file mode 100644 index 27de3904a9a..00000000000 --- a/apps/sim/app/api/v2/credential-providers/route.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ - listCredentialProviders: { - operation: { id: 'credentials.providers.list' }, - execute: mocks.execute, - }, -})) - -import { GET } from '@/app/api/v2/credential-providers/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { - kind: 'workspace_api_key' as const, - workspaceId: WORKSPACE_ID, - keyId: 'key-1', - }, - rolloutUserId: 'billing-owner-1', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, - rateLimitSubscription: null, - keyType: 'workspace' as const, -} - -describe('GET /api/v2/credential-providers', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - providers: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - }) - }) - - it('returns the full provider catalog in one page', async () => { - const request = new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - const response = await GET(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { workspaceId: WORKSPACE_ID }, - request, - }) - expect(await response.json()).toEqual({ - data: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - nextCursor: null, - }) - }) - - it('rejects query parameters it does not implement', async () => { - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` - ) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('conceals a workspace-key scope mismatch as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts new file mode 100644 index 00000000000..38362bef21f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.execute, + }, +})) + +import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('DELETE /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + }) + + it('disconnects a credential through the application operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, { + params: Promise.resolve({ credentialId: 'credential-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + request, + }) + }) + + it('requires the asserted workspace scope', async () => { + const response = await DELETE( + new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + method: 'DELETE', + }), + { params: Promise.resolve({ credentialId: 'credential-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..0baea423259 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -0,0 +1,30 @@ +import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Credential not found', +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + credentialId: params.credentialId, + }), + useCase: deleteCredentialUseCase, + present: ({ credential }) => ({ data: { id: credential.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.test.ts b/apps/sim/app/api/v2/credentials/connections/route.test.ts new file mode 100644 index 00000000000..1946b7fb81c --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credentials/connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credentials/connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('creates a browser entrypoint for a named OAuth credential', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + }) + + it('requires a display name for new OAuth connections', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-connections/route.ts rename to apps/sim/app/api/v2/credentials/connections/route.ts diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts new file mode 100644 index 00000000000..bec5f7126c8 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credentials/providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const providers = [ + { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'service_account' as const, + serviceId: 'salesforce-service-account', + providerId: 'salesforce-service-account', + name: 'Salesforce integration user app', + description: 'Connect Salesforce with an integration user app.', + providerFamily: 'salesforce', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: ['client_credentials'], + }, + ], + }, +] + +describe('GET /api/v2/credentials/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ providers }) + }) + + it('returns OAuth and service-account connection methods', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: providers, nextCursor: null }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('rejects unsupported pagination instead of ignoring it', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-providers/route.ts rename to apps/sim/app/api/v2/credentials/providers/route.ts diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..3857fc31409 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,7 +13,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - execute: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -23,13 +24,20 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { operation: { id: 'credentials.connections.list' }, - execute: mocks.execute, + execute: mocks.list, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + createServiceAccountCredentialUseCase: { + operation: { id: 'credentials.service_accounts.create' }, + execute: mocks.create, }, })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { GET } from '@/app/api/v2/credentials/route' +import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const auth = { @@ -67,7 +75,7 @@ describe('GET /api/v2/credentials', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: null, sortBy: 'createdAt', @@ -81,7 +89,7 @@ describe('GET /api/v2/credentials', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('calls the application operation with the workspace principal', async () => { @@ -91,7 +99,7 @@ describe('GET /api/v2/credentials', () => { const response = await GET(request) expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: { workspaceId: WORKSPACE_ID, @@ -114,7 +122,7 @@ describe('GET /api/v2/credentials', () => { * map of param names and stays green when a route drops the stamp entirely. */ it('refuses a cursor minted under a different filter', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -129,7 +137,7 @@ describe('GET /api/v2/credentials', () => { const { nextCursor } = await minted.json() expect(nextCursor).toEqual(expect.any(String)) - mocks.execute.mockClear() + mocks.list.mockClear() const replayed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` @@ -138,11 +146,11 @@ describe('GET /api/v2/credentials', () => { expect(replayed.status).toBe(400) expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('resumes a cursor replayed under the filters it was minted with', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -156,7 +164,7 @@ describe('GET /api/v2/credentials', () => { ) const { nextCursor } = await minted.json() - mocks.execute.mockClear() + mocks.list.mockClear() const resumed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` @@ -164,7 +172,7 @@ describe('GET /api/v2/credentials', () => { ) expect(resumed.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ search: 'zoom', @@ -202,7 +210,7 @@ describe('GET /api/v2/credentials', () => { }) it('hides repository errors that may contain secret details', async () => { - mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + mocks.list.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) @@ -214,3 +222,97 @@ describe('GET /api/v2/credentials', () => { }) }) }) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + keyType: 'personal', + }) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.create.mockResolvedValue({ + credential: { ...credential, encryptedServiceAccountKey: 'must-not-leak' }, + created: true, + hasServiceAccountKey: true, + role: 'admin', + auditMetadata: {}, + }) + }) + + it('creates a verified service-account credential without returning secrets', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }), + }) + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toMatchObject({ + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + providerId: 'zoom-service-account', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('client-secret') + expect(JSON.stringify(body)).not.toContain('must-not-leak') + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + description: undefined, + id: undefined, + serviceAccountJson: undefined, + apiToken: undefined, + domain: undefined, + signingSecret: undefined, + botToken: undefined, + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }, + request, + }) + }) + + it('rejects an unknown service-account provider before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'made-up-service-account', + serviceAccountJson: '{}', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index bccfffccbe2..207efb65ec8 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,39 +1,26 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateServiceAccountCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { cursorScopeKey } from '@/lib/api/cursor-binding' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { toV2Credential } from '@/lib/credentials/application/presentation' +import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} +const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { @@ -56,7 +43,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: credentialOperations.listConnections, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: credentialWorkspaceErrorPolicy, mapInput: ({ query }) => ({ ...query, cursorKeys: readSortedCursor( @@ -77,3 +64,18 @@ export const GET = defineV2JsonRoute({ ), }), }) + +/** POST /api/v2/credentials — Create and verify a service-account credential. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateServiceAccountCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createServiceAccount, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialWorkspaceErrorPolicy, + mapInput: ({ body }) => body, + useCase: createServiceAccountCredentialUseCase, + present: ({ credential, hasServiceAccountKey, role }) => ({ + data: toV2Credential({ ...credential, hasServiceAccountKey, role }), + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 4ce9499de71..9dd1e8aee32 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -83,7 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ - 'GET /api/v2/credential-providers', + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 60d537b2754..c7a3ab5c7fd 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -10,6 +10,11 @@ import { v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -45,27 +50,69 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output -export const v2CredentialProviderAuthorizationOptionSchema = z.object({ - providerId: z - .string() - .min(1, 'providerId cannot be empty') - .max(255, 'providerId must be at most 255 characters') - .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), - label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), -}) +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() export type V2CredentialProviderAuthorizationOption = z.output< typeof v2CredentialProviderAuthorizationOptionSchema > -export const v2CredentialProviderSchema = z +export const v2CredentialProviderFieldOptionSchema = z .object({ - serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), - name: z.string().min(1).max(255).describe('OAuth service display name.'), - description: z.string().min(1).max(1000).describe('OAuth service description.'), - providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), - available: z - .boolean() - .describe('Whether this caller can start the OAuth flow in the current deployment.'), + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, supportsReconnect: z .boolean() .describe('Whether existing credentials for this service can be reconnected.'), @@ -75,10 +122,39 @@ export const v2CredentialProviderSchema = z .max(10) .describe('Authorization servers available for this OAuth service.'), }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) .meta({ id: 'V2CredentialProvider', title: 'Credential Provider', - description: 'An OAuth service that may be connected to a workspace.', + description: 'An OAuth or service-account connection method available to a workspace.', }) export type V2CredentialProvider = z.output @@ -104,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -122,7 +193,7 @@ export const v2ListCredentialsContract = defineRouteContract({ export const v2ListCredentialProvidersQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe( - 'Workspace used to evaluate OAuth availability and integration policy.' + 'Workspace used to evaluate credential-provider availability and integration policy.' ), }) .strict() @@ -130,7 +201,7 @@ export type V2ListCredentialProvidersQuery = z.output { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 377a1cb7442..7a420995f46 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,5 +1,7 @@ import { v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, v2ListCredentialProvidersContract, v2ListCredentialsContract, } from '@/lib/api/contracts/v2/credentials' @@ -171,6 +173,7 @@ const CREDENTIAL_EXAMPLE = { } as const const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect to Salesforce CRM data and operations.', @@ -183,6 +186,44 @@ const CREDENTIAL_PROVIDER_EXAMPLE = { ], } as const +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + const CREDENTIAL_CONNECTION_EXAMPLE = { authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', expiresAt: '2026-06-20T14:17:11.000Z', @@ -806,7 +847,7 @@ const declaredRoutes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -831,9 +872,9 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'listCredentialProviders', summary: 'List Credential Providers', - description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, - success: { description: 'OAuth provider catalog with caller-specific availability.' }, + success: { description: 'Credential provider catalog with caller-specific availability.' }, }), { query: documentedSchema( @@ -846,8 +887,55 @@ const declaredRoutes = [ v2ListCredentialProvidersContract.response.schema, 'ListCredentialProvidersResponse', 'List credential providers response', - 'OAuth providers and their authorization-server options.', - [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] ), } ), @@ -861,6 +949,7 @@ const declaredRoutes = [ success: { description: 'A short-lived browser authorization URL.' }, }), { + query: v2CreateCredentialConnectionContract.query, body: documentedSchema( v2CreateCredentialConnectionContract.body, 'CreateCredentialConnectionBody', @@ -876,6 +965,37 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + operationId: 'deleteCredential', + summary: 'Disconnect Credential', + description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -1026,7 +1146,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ { name: 'Credentials', description: - 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..cdb6849481f 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,40 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('fails fast if the authorized credential disappears before deletion commits', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).rejects.toThrow('Credential disappeared during deletion') + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index ce3a6805483..143a068961e 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/credentials/application/provider-catalog', () => ({ listCredentialProviderCatalog: mocks.listCatalog, - requireAvailableCredentialProvider: ( + requireAvailableOAuthCredentialProvider: ( catalog: Array<{ available: boolean authorizationOptions: Array<{ providerId: string }> @@ -59,6 +59,7 @@ const context = { billedAccountUserId: 'billing-owner-1', } const salesforceProvider = { + type: 'oauth' as const, serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 6365988099e..8783d526463 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -3,16 +3,16 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { - type CredentialProviderCatalogEntry, listCredentialProviderCatalog, - requireAvailableCredentialProvider, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, } from '@/lib/credentials/application/provider-catalog' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { credentialProviderMatchesService } from '@/lib/oauth/utils' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ResolvedCredentialConnectionTarget { - provider: CredentialProviderCatalogEntry + provider: OAuthCredentialProviderCatalogEntry providerId: string credentialId?: string displayName?: string @@ -32,7 +32,7 @@ export async function resolveCredentialConnectionTarget(params: { const catalog = await listCredentialProviderCatalog(principal, context) if (providerId) { return { - provider: requireAvailableCredentialProvider(catalog, providerId), + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), providerId, } } @@ -61,11 +61,15 @@ export async function resolveCredentialConnectionTarget(params: { ) } - const provider = catalog.find((entry) => - credentialProviderMatchesService(credentialProviderId, { - providerId: entry.authorizationOptions[0].providerId, - additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), - }) + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 7432c1c7430..4dd53eb7dd5 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -19,6 +19,18 @@ export const credentialOperations = { workspaceApiKey: 'deny', principalKinds: ['personal_api_key'], }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + delete: defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), launchConnection: defineWorkspaceOperation({ id: 'credentials.connections.launch', minimumRole: 'write', diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..efbf73c0d07 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,26 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 51aece09f81..08a452dd5c2 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -44,7 +44,11 @@ vi.mock('@/lib/oauth/utils', () => ({ getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, })) -import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' const personalPrincipal = { kind: 'personal_api_key' as const, @@ -74,11 +78,12 @@ const services = [ authType: 'oauth' as const, }, { - serviceId: 'service-account-only', - providerId: 'service-account-only', - name: 'Service account', - description: 'Not OAuth.', - baseProvider: 'test', + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', authType: 'service_account' as const, }, ] @@ -98,6 +103,8 @@ describe('listCredentialProviderCatalog', () => { }) mocks.createVisibility.mockReturnValue({ isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', }) mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { @@ -118,6 +125,7 @@ describe('listCredentialProviderCatalog', () => { expect(catalog).toEqual([ { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', @@ -130,6 +138,7 @@ describe('listCredentialProviderCatalog', () => { ], }, { + type: 'oauth', serviceId: 'trello', name: 'Trello', description: 'Connect Trello.', @@ -138,6 +147,28 @@ describe('listCredentialProviderCatalog', () => { supportsReconnect: false, authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, ]) expect(mocks.createVisibility).toHaveBeenCalledWith( expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) @@ -174,3 +205,33 @@ describe('listCredentialProviderCatalog', () => { ) }) }) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 760cda016d0..90e8d49d500 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -2,7 +2,21 @@ import type { Principal } from '@sim/auth/principal' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -12,21 +26,195 @@ export interface CredentialProviderAuthorizationOption { label: string } -export interface CredentialProviderCatalogEntry { +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' serviceId: string name: string description: string providerFamily: string available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' supportsReconnect: boolean authorizationOptions: CredentialProviderAuthorizationOption[] } +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + interface CredentialProviderCatalogContext { workspaceId: string workspaceOrganizationId: string | null } +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + function principalUserId(principal: Principal): string | undefined { if (principal.kind === 'session' || principal.kind === 'personal_api_key') { return principal.userId @@ -60,14 +248,15 @@ export async function listCredentialProviderCatalog( ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), }), ]) - const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') const visibility = createIntegrationCredentialVisibility({ allowedIntegrationTypes: allowedIntegrations, blockVisibility, - oauthServices, + oauthServices: services, }) - return oauthServices.map((service) => { + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { const config = getServiceConfigByServiceId(service.serviceId) if (!config) { throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) @@ -85,6 +274,7 @@ export async function listCredentialProviderCatalog( }) return { + type: 'oauth', serviceId: service.serviceId, name: service.name, description: service.description, @@ -94,14 +284,47 @@ export async function listCredentialProviderCatalog( authorizationOptions, } }) + + const serviceAccountOwners = new Map() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] } -export function requireAvailableCredentialProvider( +export function requireAvailableOAuthCredentialProvider( catalog: readonly CredentialProviderCatalogEntry[], providerId: string -): CredentialProviderCatalogEntry { - const provider = catalog.find((entry) => - entry.authorizationOptions.some((option) => option.providerId === providerId) +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) @@ -111,3 +334,23 @@ export function requireAvailableCredentialProvider( } return provider } + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..37a384a6752 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + delete: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteConnectionCredential: mocks.delete, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential }) + expect(mocks.delete).toHaveBeenCalledWith({ + credentialId: credential.id, + workspaceId: WORKSPACE_ID, + reason: 'user_delete', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..ac7e766cc47 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,211 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteConnectionCredential, +} from '@/lib/credentials/orchestration' +import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +function principalUserId(principal: Extract): string { + return principal.userId +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: principalUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal)) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + principalUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +interface CredentialApplicationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credential: CredentialRow +} + +export interface DeleteCredentialInput { + workspaceId: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow +} + +async function resolveCredentialContext( + input: DeleteCredentialInput +): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + const credential = await getWorkspaceCredential({ + workspaceId: workspace.workspaceId, + credentialId: input.credentialId, + }) + if (!credential || !['oauth', 'service_account'].includes(credential.type)) { + throw new OrchestrationError('not_found', 'Credential not found') + } + return { ...workspace, credential } +} + +export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.delete, + resolveContext: async ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialContext(input), + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + const userId = principalUserId(principal) + const actor = await getCredentialActorContext(context.credential.id, userId) + if (!actor.credential || !actor.hasWorkspaceAccess) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + + await deleteConnectionCredential({ + credentialId: input.credentialId, + workspaceId: context.workspaceId, + reason: 'user_delete', + }) + return { credential: context.credential } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + }), + afterSuccess: ({ principal, context, result }) => { + captureServerEvent( + principalUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type as 'oauth' | 'service_account', + provider_id: result.credential.providerId ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..e42081e57fc 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,27 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 7a09f8a4f16..785f3320c7f 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -5,9 +5,9 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -81,7 +81,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -95,6 +95,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record } interface ExistingCredentialSourceParams { @@ -190,14 +192,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -332,7 +337,7 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { @@ -484,37 +489,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -570,6 +545,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff0a3bd283f..bab184fc3a4 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -32,7 +32,10 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5a40d039ee0..6665ac74a79 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1107, - zodRoutes: 1107, + totalRoutes: 1108, + zodRoutes: 1108, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 191fb41000a..2fb9c71e064 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 24], + ['apps/docs/openapi-v2-resources.json', 26], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(137) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => { From 7fcf26fca5be1d0c6c7c519af90e0aad4d5fd8f8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:41:06 -0700 Subject: [PATCH 5/6] fix(credentials): make disconnect idempotent --- .../__tests__/webhook-deactivation.test.ts | 7 ++-- .../application/service-account.test.ts | 14 +++++++- .../application/service-account.ts | 35 +++++++++++-------- apps/sim/lib/credentials/deletion.ts | 17 +++++---- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index cdb6849481f..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -53,18 +53,19 @@ describe('deleteConnectionCredential', () => { it('deletes exactly one credential within its canonical workspace scope', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: 'credential-1', workspaceId: 'workspace-1', reason: 'user_delete', }) + expect(deleted).toBe(true) expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') }) - it('fails fast if the authorized credential disappears before deletion commits', async () => { + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) await expect( @@ -73,6 +74,6 @@ describe('deleteConnectionCredential', () => { workspaceId: 'workspace-1', reason: 'user_delete', }) - ).rejects.toThrow('Credential disappeared during deletion') + ).resolves.toBe(false) }) }) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 37a384a6752..9da01986a6d 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -92,6 +92,7 @@ describe('credential service-account application operations', () => { auditMetadata: { tenantId: 'tenant-1' }, }) mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.delete.mockResolvedValue(true) mocks.requireProvider.mockReturnValue({ type: 'service_account', providerId: 'zoom-service-account', @@ -199,11 +200,22 @@ describe('credential service-account application operations', () => { input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, }) - expect(result).toEqual({ credential }) + expect(result).toEqual({ credential, deleted: true }) expect(mocks.delete).toHaveBeenCalledWith({ credentialId: credential.id, workspaceId: WORKSPACE_ID, reason: 'user_delete', }) }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.delete.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) }) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index ac7e766cc47..5954a910b2e 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -141,6 +141,7 @@ export interface DeleteCredentialInput { export interface DeleteCredentialResult { credential: CredentialRow + deleted: boolean } async function resolveCredentialContext( @@ -176,27 +177,31 @@ export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ ) } - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: input.credentialId, workspaceId: context.workspaceId, reason: 'user_delete', }) - return { credential: context.credential } + return { credential: context.credential, deleted } }, - projectAudit: ({ result }) => ({ - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: result.credential.id, - resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, - metadata: { - reason: 'user_delete', - credentialType: result.credential.type, - providerId: result.credential.providerId, - accountId: result.credential.accountId, - }, - }), + projectAudit: ({ result }) => + result.deleted + ? { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + } + : [], afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return captureServerEvent( principalUserId(principal), 'credential_deleted', diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index e42081e57fc..f16902ddf04 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -80,7 +80,7 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< /** Clears references and deletes one connection without surface audit attribution. */ export async function deleteConnectionCredential( params: DeleteConnectionCredentialParams -): Promise { +): Promise { const { credentialId, workspaceId } = params await clearCredentialRefs(credentialId, workspaceId) const deleted = await db @@ -89,13 +89,16 @@ export async function deleteConnectionCredential( and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) ) .returning({ id: schema.credential.id }) - if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') - logger.info('Deleted credential', { - credentialId, - workspaceId, - reason: params.reason, - }) + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 } /** From cf680e6550bcb0d510591ef204e07e11e59fe45f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:49:24 -0700 Subject: [PATCH 6/6] fix(credentials): stabilize oauth draft retries --- .../create-credential-connection.test.ts | 2 ++ .../create-credential-connection.ts | 1 + .../sim/lib/credentials/connect-draft.test.ts | 23 ++++++++++++++++++- apps/sim/lib/credentials/connect-draft.ts | 15 +++++++----- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index b934755f0e5..544edd2130b 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -97,6 +97,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: undefined, displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', @@ -123,6 +124,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: 'credential-1', displayName: 'Existing Gmail', + displayNameDefinesIntent: false, }) }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 513e2b21c85..4aaae28a61c 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -42,6 +42,7 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ providerId: target.providerId, credentialId: target.credentialId, displayName, + displayNameDefinesIntent: input.providerId !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index 4394d86067b..539a7cac8c5 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ @@ -28,6 +28,7 @@ describe('createConnectDraft', () => { workspaceId: 'workspace-1', providerId: 'google-email', displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(dbChainMockFns.values).toHaveBeenCalledWith( @@ -43,6 +44,26 @@ describe('createConnectDraft', () => { expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + it('refreshes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + + expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( + schemaMock.pendingCredentialDraft.displayName, + 'Renamed Gmail' + ) + }) + it('fails fast when an active draft has a different connection intent', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 23a7e4ca34a..6c2565e3d55 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -29,6 +29,8 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean }): Promise { const { userId, workspaceId, providerId, credentialId } = params @@ -67,6 +69,12 @@ export async function createConnectDraft(params: { and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) const id = generateId() + const sameTarget = credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + const sameIntent = params.displayNameDefinesIntent + ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) + : sameTarget const [draft] = await db .insert(pendingCredentialDraft) .values({ @@ -86,12 +94,7 @@ export async function createConnectDraft(params: { pendingCredentialDraft.workspaceId, ], set: { expiresAt, createdAt: now }, - setWhere: and( - eq(pendingCredentialDraft.displayName, displayName), - credentialId - ? eq(pendingCredentialDraft.credentialId, credentialId) - : isNull(pendingCredentialDraft.credentialId) - ), + setWhere: sameIntent, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt })