diff --git a/apps/docs/content/docs/en/integrations/logrocket.mdx b/apps/docs/content/docs/en/integrations/logrocket.mdx index b986ace5ccb..de718d20190 100644 --- a/apps/docs/content/docs/en/integrations/logrocket.mdx +++ b/apps/docs/content/docs/en/integrations/logrocket.mdx @@ -188,4 +188,3 @@ Register a release version in LogRocket so uploaded source maps can decode stack | --------- | ---- | ----------- | | `version` | string | Release version that was registered | - diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 5b513c1c03d..128e2186bcd 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 reach 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 e842423e106..10929131347 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 reach 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 24157c46f4e..b4cb8bdf801 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 reach 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 0a64c9f581f..846bd5e9603 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -600,7 +600,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 reach 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 51e85c8125b..52a97b1a71c 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 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,101 +1703,26 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/secrets": { - "get": { - "operationId": "listSecrets", - "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace whose secret metadata should be listed.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Workspace whose secret metadata should be listed." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Restrict results to one ownership scope.", - "schema": { - "description": "Restrict results to one ownership scope.", - "type": "string", - "enum": ["workspace", "personal"] - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the secret name.", - "schema": { - "description": "Case-insensitive substring match against the secret name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 + }, + "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": "Secret metadata visible to the caller.", + "description": "An existing credential matched the verified source.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1812,7 +1737,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSecretsResponse" + "$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" } } } @@ -1829,6 +1775,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1841,41 +1793,28 @@ } } }, - "/api/v2/secrets/{name}": { - "put": { - "operationId": "setSecret", - "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "/api/v2/credentials/providers": { + "get": { + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "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": "name", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Workspace used to evaluate credential-provider availability and integration policy." } } ], - "requestBody": { - "required": true, - "description": "Ownership scope and write-only value for the secret.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetSecretRequest" - } - } - } - }, "responses": { "200": { - "description": "The existing secret value was replaced.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1890,13 +1829,55 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretResponse" + "$ref": "#/components/schemas/ListCredentialProvidersResponse" } } } }, - "201": { - "description": "The secret was created.", + "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/credentials/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" @@ -1911,7 +1892,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretResponse" + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" } } } @@ -1928,6 +1909,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1941,52 +1925,42 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/credentials/{credentialId}": { "delete": { - "operationId": "deleteSecret", - "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "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": "name", + "name": "credentialId", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Credential to disconnect.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Credential to disconnect." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "description": "Workspace expected to own the credential.", "schema": { "type": "string", "minLength": 1, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." - } - }, - { - "name": "scope", - "in": "query", - "required": true, - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", - "schema": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Workspace expected to own the credential." } } ], "responses": { "200": { - "description": "The secret was deleted.", + "description": "The credential was disconnected.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2001,7 +1975,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSecretResponse" + "$ref": "#/components/schemas/DeleteCredentialResponse" } } } @@ -2029,37 +2003,362 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } + "/api/v2/secrets": { + "get": { + "operationId": "listSecrets", + "summary": "List Secrets", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose secret metadata should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace whose secret metadata should be listed." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Restrict results to one ownership scope.", + "schema": { + "description": "Restrict results to one ownership scope.", + "type": "string", + "enum": ["workspace", "personal"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the secret name.", + "schema": { + "description": "Case-insensitive substring match against the secret name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Secret metadata visible to the caller.", + "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/ListSecretsResponse" + } + } + } + }, + "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/{name}": { + "put": { + "operationId": "setSecret", + "summary": "Set Secret", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + } + ], + "requestBody": { + "required": true, + "description": "Ownership scope and write-only value for the secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSecretRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The existing secret value was replaced.", + "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/SetSecretResponse" + } + } + } + }, + "201": { + "description": "The secret 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/SetSecretResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteSecret", + "summary": "Delete Secret", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." + } + }, + { + "name": "scope", + "in": "query", + "required": true, + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + } + } + ], + "responses": { + "200": { + "description": "The secret was deleted.", + "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/DeleteSecretResponse" + } + } + } + }, + "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" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } }, "X-RateLimit-Reset": { "description": "ISO 8601 timestamp when the current rate-limit window resets.", @@ -2264,7 +2563,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 reach 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"], @@ -3509,70 +3808,263 @@ "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 tool was last updated." } - }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }" + } + ] }, - "ListCustomToolsResponse": { + "GetCustomToolResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2CustomTool" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "Get custom tool response", + "description": "One custom tool.", "examples": [ { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } } ] }, - "CreateCustomToolResponse": { + "UpdateCustomToolResponse": { "type": "object", "properties": { "data": { @@ -3582,8 +4074,8 @@ }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Update custom tool response", + "description": "The updated custom tool.", "examples": [ { "data": { @@ -3605,28 +4097,29 @@ } } }, - "code": "return { ok: true }", + "code": "return { ok: false }", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "CreateCustomToolRequest": { + "UpdateCustomToolRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, - "description": "Workspace in which to create the custom tool." + "description": "Workspace that owns the custom tool." }, "title": { + "description": "New display title for the tool.", "type": "string", "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "maxLength": 200 }, "schema": { + "description": "Replacement function declaration.", "type": "object", "properties": { "type": { @@ -3688,388 +4181,820 @@ "required": ["type", "function"], "additionalProperties": { "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + } }, "code": { + "description": "Replacement tool implementation.", "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "maxLength": 100000 } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" + "code": "return { ok: false }" + } + ] + }, + "V2CustomToolDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted custom tool." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." + }, + "DeleteCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomToolDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } } ] }, - "GetCustomToolResponse": { + "V2Credential": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique credential identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional credential description." + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." + }, + "createdAt": { + "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 credential was created." + }, + "updatedAt": { + "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 credential was last updated." + } + }, + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." + }, + "ListCredentialsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } + "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" + } + ], + "nextCursor": null } ] }, - "UpdateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", - "examples": [ + "V2CredentialProvider": { + "oneOf": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "Workspace that owns the custom tool." - }, - "title": { - "description": "New display title for the tool.", - "type": "string", - "minLength": 1, - "maxLength": 200 + "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 + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false }, - "schema": { - "description": "Replacement function declaration.", + { "type": "object", "properties": { "type": { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "const": "service_account", + "description": "Direct service-account credential method." }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { + "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." + }, + "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", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } + "required": ["value", "label"], + "additionalProperties": false } }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false }, - "description": "OpenAI-style function definition." + "description": "Create-body fields accepted by this provider. Secret fields are write-only." } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - } + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + "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": ["workspaceId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" + "data": [ + { + "type": "oauth", + "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" + } + ] + }, + { + "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 } ] }, - "V2CustomToolDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted custom tool." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." - }, - "DeleteCustomToolResponse": { + "CreateServiceAccountCredentialResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" + "$ref": "#/components/schemas/V2Credential" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "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" } } ] }, - "V2Credential": { + "CreateServiceAccountCredentialRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique credential identifier." + "minLength": 1, + "description": "Workspace that will own the credential." }, "type": { "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "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", - "description": "Credential display name." + "minLength": 1, + "maxLength": 255 }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "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)$" }, - "accountId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Linked account identifier for OAuth credentials." + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 }, - "role": { + "domain": { + "description": "Provider account domain.", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "minLength": 1, + "maxLength": 2048 }, - "createdAt": { + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, "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 credential was created." + "minLength": 1, + "maxLength": 8192 }, - "updatedAt": { + "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 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "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": { + "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 credential was last updated." + "description": "ISO 8601 timestamp when the connection link expires." } }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], + "required": ["authorizationUrl", "expiresAt"], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." }, - "ListCredentialsResponse": { + "CreateCredentialConnectionResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2Credential" + "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 OAuth provider ID returned by credential-provider discovery." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } }, - "description": "Items in the current page." + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." }, - { - "type": "null" + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "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." + }, + "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": ["data", "nextCursor"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "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", - "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" - } - ], - "nextCursor": null + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } } ] }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index b1bff5d7222..429ce4a0eff 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 reach 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 6d9fdd9da01..3d356d738bf 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 reach 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/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts new file mode 100644 index 00000000000..7c5eb98a75a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), + createConnectDraft: vi.fn(), + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createConnectDraft, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['instagram_business_basic'], +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +import { GET } from '@/app/api/auth/instagram/authorize/route' + +describe('Instagram authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, + }) + mocks.checkWorkspaceAccess.mockResolvedValue({ canWrite: true }) + mocks.createConnectDraft.mockResolvedValue({ id: 'draft-created' }) + }) + + it('preserves an exact credential draft when workspaceId is also supplied', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-exact' + ) + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.createConnectDraft).not.toHaveBeenCalled() + }) + + it('creates a credential draft for a legacy workspace-only launch', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-created' + ) + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(mocks.createConnectDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'instagram', + }) + }) +}) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index b33a0c0c510..84b0154e706 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -18,6 +18,7 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -34,18 +35,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeInstagramContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl, workspaceId } = parsed.data.query + const { returnUrl, workspaceId, draftId } = parsed.data.query + let credentialDraftId = draftId - if (workspaceId) { + if (workspaceId && !draftId) { const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.canWrite) { return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) } - await createConnectDraft({ + const draft = await createConnectDraft({ userId: session.user.id, workspaceId, providerId: 'instagram', }) + credentialDraftId = draft.id } const baseUrl = getBaseUrl() @@ -68,6 +71,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) + if (credentialDraftId) { + response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { 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..a39d6f8f0a2 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&credentialDraftId=draft-1`, + 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&draftId=draft-1` + ) + 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( @@ -131,8 +214,13 @@ describe('OAuth2 authorize route', () => { }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ setWhere: expect.anything() }) + ) + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), + body: expect.objectContaining({ + callbackURL: `${BASE_URL}/workspace?credentialDraftId=draft-1`, + }), }) ) }) @@ -149,11 +237,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 () => { @@ -200,7 +289,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( @@ -221,9 +310,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() }) ) }) @@ -245,18 +332,26 @@ describe('OAuth2 authorize route', () => { ) }) - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { + it('binds custom-provider reconnects to the exact draft', async () => { + setEnv({ + TRELLO_API_KEY: 'trello-key', + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }) for (const providerId of ['trello', 'shopify']) { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { providerId } }) + ) const response = await GET( authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) ) expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` + `${BASE_URL}/api/auth/${providerId}/authorize?returnUrl=https%3A%2F%2Fsim.test%2Fworkspace&draftId=draft-1` ) } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.values).toHaveBeenCalledTimes(2) expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..98543d53099 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -4,10 +4,13 @@ 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 { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') @@ -30,92 +33,131 @@ 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 + let connectionDraftId: string | undefined + 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 + connectionDraftId = draft.id + 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) { + // 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) { + const draft = await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) + connectionDraftId = draft.id + } + + if (!connectionDraftId) { + throw new Error('OAuth authorization is missing its credential draft id') + } + + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('draftId', connectionDraftId) + return NextResponse.redirect(authorizeUrl) + } + + const stateCallbackUrl = new URL(callbackURL) + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL: stateCallbackUrl.toString(), + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 4aea1372f83..19284a950fc 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -33,11 +33,16 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' function clearOAuthCookies(response: NextResponse) { response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) return response } @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { code, state, error, error_reason, error_description } = parsed.data.query + const draftId = request.cookies.get(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE)?.value if (error) { logger.warn('Instagram OAuth denied by user', { @@ -293,17 +299,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'instagram', - accountId: persisted.id, - }) - } catch (draftError) { - logger.error('Failed to process credential draft for Instagram', { error: draftError }) - } + if (!persisted) { + throw new Error(`Instagram OAuth account ${igUserId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts new file mode 100644 index 00000000000..664a50bc714 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } = + vi.hoisted(() => ({ + mockCompleteShopifyOAuthConnection: vi.fn(), + mockGetSession: vi.fn(), + mockRequireConfiguredOAuthClient: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/oauth/shopify', () => ({ + completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection, +})) + +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' +import { GET } from '@/app/api/auth/oauth2/callback/shopify/route' + +const CLIENT_SECRET = 'shopify-client-secret' +const SHOP_DOMAIN = 'example.myshopify.com' + +function callbackRequest(state: string) { + const searchParams = new URLSearchParams({ + code: 'authorization-code', + shop: SHOP_DOMAIN, + state, + }) + const message = [...searchParams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&') + searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET)) + + return createMockRequest( + 'GET', + undefined, + { + cookie: + 'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected', + }, + `https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}` + ) +} + +describe('Shopify OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockRequireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client-id', + SHOPIFY_CLIENT_SECRET: CLIENT_SECRET, + }, + }) + mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + }) + + it('completes the credential draft carried by signed state instead of a shared cookie', async () => { + const state = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-from-state', + clientSecret: CLIENT_SECRET, + }) + + const response = await GET(callbackRequest(state)) + + expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({ + accessToken: 'shopify-token', + shopDomain: SHOP_DOMAIN, + scope: 'read_products', + userId: 'user-1', + draftId: 'draft-from-state', + signal: expect.any(AbortSignal), + }) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 2292a76a9a6..bf2907856d2 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' +import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' +function clearShopifyOAuthCookies(response: NextResponse): NextResponse { + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') + response.cookies.delete('shopify_pending_token') + response.cookies.delete('shopify_pending_shop') + response.cookies.delete('shopify_pending_scope') + response.cookies.delete('shopify_return_url') + return response +} + /** * Validates the HMAC signature from Shopify to ensure the request is authentic * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens @@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { shop: searchParams.get('shop') || undefined, }) - const storedState = request.cookies.get('shopify_oauth_state')?.value - const storedShop = request.cookies.get('shopify_shop_domain')?.value - const { values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') @@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) } - if (!state || state !== storedState) { - logger.error('State mismatch in Shopify OAuth callback') + if (!state) { + logger.error('Missing state in Shopify OAuth callback') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) } @@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) } - const shopDomain = shop || storedShop + const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) @@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } + const { draftId } = parseShopifyOAuthState({ + state, + userId: session.user.id, + shopDomain, + clientSecret, + }) + const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, { method: 'POST', headers: { @@ -127,44 +145,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) } - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`) - - const response = NextResponse.redirect(storeUrl) - - response.cookies.set('shopify_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_shop', shopDomain, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - response.cookies.delete('shopify_oauth_state') - response.cookies.delete('shopify_shop_domain') + const returnUrlCookie = request.cookies.get('shopify_return_url')?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('shopify_connected', 'true') - return response + return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl)) } catch (error) { logger.error('Error in Shopify OAuth callback:', error) const errorCode = error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'shopify_config_error' : 'shopify_callback_error' - return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + return clearShopifyOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 182989f917a..d3c84d68883 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { shopifyShopDomainSchema, @@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/lib/oauth/credential-service' -import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' const logger = createLogger('ShopifyStore') @@ -41,95 +36,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + const draftId = request.cookies.get('shopify_credential_draft_id')?.value if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format in cookie', { shopDomain }) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch( - `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, - { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - } - ) - - if (!shopResponse.ok) { - const errorText = await shopResponse.text() - logger.error('Invalid Shopify token', { - status: shopResponse.status, - error: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`) - } - - const shopData = await shopResponse.json() - const shopInfo = shopData.shop - const stableAccountId = shopInfo.id?.toString() || shopDomain - - const existing = await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - const now = new Date() - - const accountData = { - accessToken: accessToken, - accountId: stableAccountId, - scope: scope || '', - updatedAt: now, - idToken: shopDomain, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing Shopify account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `shopify_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'shopify', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'Shopify', identifier: shopDomain } - ) - } - - const persisted = - existing ?? - (await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - })) - - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Shopify', { error }) - } - } - const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') @@ -139,6 +61,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_pending_shop') response.cookies.delete('shopify_pending_scope') response.cookies.delete('shopify_return_url') + response.cookies.delete('shopify_credential_draft_id') return response } catch (error) { diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index d2d3a5401e4..9ef6909e581 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { shopifyAuthorizeQuerySchema, @@ -10,6 +9,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') @@ -26,19 +26,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { - values: { SHOPIFY_CLIENT_ID: clientId }, + values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') const query = shopifyAuthorizeQuerySchema.parse({ shop: request.nextUrl.searchParams.get('shop') || undefined, returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined, + draftId: request.nextUrl.searchParams.get('draftId') || undefined, }) - const { shop: shopDomain, returnUrl } = query + const { shop: shopDomain, returnUrl, draftId } = query if (!shopDomain) { const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : '' const returnUrlJsLiteral = JSON.stringify(safeReturnUrl) + const draftIdJsLiteral = JSON.stringify(draftId ?? '') return new NextResponse( ` @@ -127,6 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { @@ -169,7 +175,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` - const state = generateId() + const state = createShopifyOAuthState({ + userId: session.user.id, + shopDomain: cleanShop, + draftId, + clientSecret, + }) const oauthUrl = `https://${cleanShop}/admin/oauth/authorize?` + @@ -189,21 +200,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = NextResponse.redirect(oauthUrl) - response.cookies.set('shopify_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('shopify_shop_domain', cleanShop, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set('shopify_return_url', returnUrl, { @@ -213,6 +212,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: 60 * 10, path: '/', }) + } else { + response.cookies.delete('shopify_return_url') } return response diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..c24cd32d61d 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -16,6 +16,7 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -28,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query + const { returnUrl: requestedReturnUrl, draftId } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -60,6 +61,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) + if (draftId) { + response.cookies.set(TRELLO_CREDENTIAL_DRAFT_COOKIE, draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + path: TRELLO_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: TRELLO_CREDENTIAL_DRAFT_COOKIE, + path: TRELLO_STATE_COOKIE_PATH, + }) + } if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { httpOnly: true, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 12233c934a4..22d9a04aefa 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -18,11 +18,13 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + response.cookies.delete({ name: TRELLO_CREDENTIAL_DRAFT_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } @@ -37,6 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(storeTrelloTokenContract, request, {}) if (!parsed.success) return parsed.response const { token, state } = parsed.data.body + const draftId = request.cookies.get(TRELLO_CREDENTIAL_DRAFT_COOKIE)?.value const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value if (!cookieState || cookieState !== state) { @@ -135,17 +138,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'trello', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Trello', { error }) - } + if (!persisted) { + throw new Error(`Trello OAuth account ${trelloUser.id} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'trello', + accountId: persisted.id, + }) return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { 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/credentials/connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credentials/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/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/credentials/providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/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/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..c06ce24edcf 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,98 @@ 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', + certificateId: undefined, + 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 b0dde7a39ab..d3f8c12b27c 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 { cursorRoute, 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/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..5cc31c9ec61 --- /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..74f7afea2c9 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -47,12 +47,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -129,6 +135,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -218,6 +225,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -262,12 +270,42 @@ 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: oauthCredentialDraftIdSchema.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 5004b64c806..598724c28cd 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/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 6694a626a3c..04c21e002d3 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,14 +1,20 @@ 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, + v2DataResponse, v2PaginationFields, v2SearchSchema, 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 @@ -44,6 +50,114 @@ 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, '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 v2CredentialProviderFieldOptionSchema = z + .object({ + 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.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .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 or service-account connection method available 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] @@ -66,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', @@ -80,3 +189,271 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate credential-provider availability and integration policy.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/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 OAuth provider ID returned by credential-provider discovery.'), + 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/credentials/connections', + query: noInputSchema, + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) + +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2CreateServiceAccountCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + type: z.literal('service_account').describe('Service-account credential discriminator.'), + 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 service-account provider ID returned by provider discovery.'), + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('Optional name; providers may derive one from the verified account identity.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .optional() + .describe('Optional credential description.'), + id: z + .string() + .uuid('id must be a valid UUID') + .optional() + .describe('Required only when provider discovery requests a client-generated ID.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + 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 a87fa3f3294..7a420995f46 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,10 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +172,63 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', + 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 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', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -784,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.' }, }), @@ -804,6 +867,135 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + 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: 'Credential 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 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 }] + ), + } + ), + 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.' }, + }), + { + query: v2CreateCredentialConnectionContract.query, + 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( + 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', { @@ -953,7 +1145,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account 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', diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..dc4ba62ca70 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -97,6 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -523,8 +524,19 @@ export const auth = betterAuth({ } } + let credentialDraftId: string | undefined try { + const oauthState = await getOAuthState() + const rawCallbackUrl = oauthState?.callbackURL + if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + credentialDraftId = rawCallbackUrl + ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? + undefined) + : undefined await processCredentialDraft({ + draftId: credentialDraftId, userId: account.userId, providerId: account.providerId, accountId: account.id, @@ -535,6 +547,7 @@ export const auth = betterAuth({ providerId: account.providerId, error, }) + if (credentialDraftId) throw error } try { diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index aa2f4e5e494..067ad94ff10 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -181,6 +181,65 @@ describe('defineAuthorizedWorkspaceUseCase', () => { expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) }) + it('runs resource authorization after workspace authorization and before business effects', async () => { + mocks.resolvePermission.mockImplementation(async () => { + mocks.events.push('workspaceAuthorization') + return 'write' + }) + const execute = vi.fn(async () => { + mocks.events.push('execute') + return { ok: true as const } + }) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => { + mocks.events.push('canonicalLoad') + return canonicalContext + }, + authorizationOptions: {}, + authorizeResource() { + mocks.events.push('resourceAuthorization') + }, + execute, + projectAudit: () => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + }), + afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await useCase.authorize?.({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + ]) + expect(execute).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + + mocks.events.length = 0 + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + ).resolves.toEqual({ ok: true }) + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + 'execute', + 'audit', + 'afterSuccess', + ]) + }) + it('supports zero or many semantic audit entries', async () => { const buildUseCase = (auditCount: number) => defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index a3286535edb..0d37830b458 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -56,6 +56,8 @@ export interface AuthorizedWorkspaceUseCaseDefinition< | (( args: AuthorizedWorkspaceUseCaseContext ) => WorkspaceAuthorizationOptions | Promise>) + /** Applies current domain-resource policy after workspace authorization. */ + authorizeResource?(args: AuthorizedWorkspaceUseCaseContext): void | Promise execute(args: AuthorizedWorkspaceUseCaseContext): Promise projectAudit?( args: AuthorizedWorkspaceUseCaseResultContext @@ -111,7 +113,8 @@ export function defineAuthorizedWorkspaceUseCase< >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { /** * Everything that runs before the business transaction: allowed-principal - * check, canonical load, asserted-scope comparison, current access check. + * check, canonical load, asserted-scope comparison, current workspace and + * resource access checks. * * `execute` and `authorize` share it rather than each spelling it out, so a * `HEAD` probe cannot answer a different question from the `GET` it stands @@ -145,6 +148,7 @@ export function defineAuthorizedWorkspaceUseCase< context, authorizationOptions ) + await definition.authorizeResource?.(executionContext) return executionContext } diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..2197f839434 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 reach 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 { /** * Runs everything {@link execute} does up to and including resource * authorization, then stops — allowed-principal check, canonical load, - * asserted-scope comparison, current access check — but not the business - * transaction, the audit projection, or the after-success effects. + * asserted-scope comparison, current workspace access check, resource access + * check — but not the business transaction, the audit projection, or the + * after-success effects. * * It exists for one caller: a surface that must answer *"would this principal * be allowed?"* without causing what the answer would cause. `HEAD` on a route diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..2fc567d9205 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,41 @@ 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' }]) + + 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('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts new file mode 100644 index 00000000000..2fe52fb957e --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -0,0 +1,64 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import type { CredentialAdminOperation } from '@/lib/credentials/application/operations' +import type { CredentialRow } from '@/lib/credentials/queries' + +export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { + credential: CredentialRow +} + +type AuthorizedCredentialUseCaseDefinition< + O extends CredentialAdminOperation, + I, + C extends CredentialAuthorizationContext, + R, +> = Omit< + AuthorizedWorkspaceUseCaseDefinition, + 'authorizationOptions' | 'authorizeResource' +> + +export function defineAuthorizedCredentialUseCase< + const O extends CredentialAdminOperation, + I, + C extends CredentialAuthorizationContext, + R, +>(definition: AuthorizedCredentialUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: {}, + async authorizeResource({ principal, context }) { + const actor = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if ( + !actor.credential || + actor.credential.workspaceId !== context.workspaceId || + !actor.hasWorkspaceAccess + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + switch (definition.operation.minimumCredentialRole) { + case 'admin': + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + return + default: + throw new Error( + `Unsupported credential role: ${definition.operation.minimumCredentialRole}` + ) + } + }, + }) +} diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts new file mode 100644 index 00000000000..143a068961e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableOAuthCredentialProvider: ( + 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 = { + 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' }, + ], +} +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..8783d526463 --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,96 @@ +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 { + listCredentialProviderCatalog, + 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: OAuthCredentialProviderCatalogEntry + 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: requireAvailableOAuthCredentialProvider(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): 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}`) + } + 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..544edd2130b --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,130 @@ +/** + * @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', + displayNameDefinesIntent: true, + }) + 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', + displayNameDefinesIntent: false, + }) + }) +}) 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..4aaae28a61c --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,54 @@ +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, + displayNameDefinesIntent: input.providerId !== undefined, + }) + 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.test.ts b/apps/sim/lib/credentials/application/operations.test.ts new file mode 100644 index 00000000000..2fd4091fb7b --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { + credentialOperations, + defineCredentialAdminOperation, +} from '@/lib/credentials/application/operations' + +describe('credential operations', () => { + it('declares credential admin as the delete authority and workspace read as reach', () => { + expect(credentialOperations.delete).toMatchObject({ + id: 'credentials.delete', + minimumRole: 'read', + minimumCredentialRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }) + expect(Object.isFrozen(credentialOperations.delete)).toBe(true) + }) + + it('rejects actorless workspace keys for credential admin operations', () => { + const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], + }) + + expect(() => defineCredentialAdminOperation(workspaceKeyOperation)).toThrow( + 'Credential admin operation credentials.test_admin requires a human principal' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..ec5b7181635 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,10 +1,56 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +export type CredentialAdminOperation = O & { + readonly minimumCredentialRole: 'admin' +} + +/** Adds credential-admin policy to a workspace-scoped operation. */ +export function defineCredentialAdminOperation( + operation: O +): CredentialAdminOperation { + if (operation.principalKinds.includes('workspace_api_key')) { + throw new Error(`Credential admin operation ${operation.id} requires a human principal`) + } + return Object.freeze({ ...operation, minimumCredentialRole: 'admin' as const }) +} 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'], + }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + delete: defineCredentialAdminOperation( + defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + 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/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 new file mode 100644 index 00000000000..f485a80465d --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,237 @@ +/** + * @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, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} 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: '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, + }, +] + +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', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', + }) + 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([ + { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'oauth', + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: true, + 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']) }) + ) + }) + + 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' + ) + }) +}) + +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 new file mode 100644 index 00000000000..7dcdc507dbf --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,356 @@ +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' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +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 + } + 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 services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices: services, + }) + + 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`) + } + 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 { + type: 'oauth', + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: true, + 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 requireAvailableOAuthCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): 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}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + 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..f4b00811eda --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,278 @@ +/** + * @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.delete.mockResolvedValue(true) + 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('rejects workspace keys before canonical loading on disconnect', async () => { + await expect( + deleteCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('allows an explicit credential admin with workspace read access to disconnect', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).resolves.toEqual({ credential, deleted: true }) + expect(mocks.delete).toHaveBeenCalledOnce() + }) + + it('applies credential admin policy during authorization-only checks', async () => { + await deleteCredentialUseCase.authorize?.({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('enforces personal-key workspace policy before credential authorization', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspace, allowPersonalApiKeys: false }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + expect(mocks.getActor).not.toHaveBeenCalled() + 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, 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 new file mode 100644 index 00000000000..d72c1911864 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,203 @@ +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 { + type CredentialAuthorizationContext, + defineAuthorizedCredentialUseCase, +} from '@/lib/credentials/application/authorized-credential-use-case' +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 extends CredentialAuthorizationContext { + billedAccountUserId: string +} + +export interface DeleteCredentialInput { + workspaceId: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow + deleted: boolean +} + +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 = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.delete, + resolveContext: async ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialContext(input), + async execute({ input, context }): Promise { + const deleted = await deleteConnectionCredential({ + credentialId: input.credentialId, + workspaceId: context.workspaceId, + reason: 'user_delete', + }) + return { credential: context.credential, deleted } + }, + 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', + { + 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/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..539a7cac8c5 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } 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('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 }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + displayNameDefinesIntent: true, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record; setWhere?: unknown } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + 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('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([]) + + 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 2e72f796526..6c2565e3d55 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,13 +2,21 @@ 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, 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' 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 +29,9 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,42 +42,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 +68,17 @@ export async function createConnectDraft(params: { .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + 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({ - id: generateId(), + id, userId, workspaceId, providerId, @@ -97,11 +93,17 @@ 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: sameIntent, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + 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, @@ -109,4 +111,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/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..f16902ddf04 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,30 @@ 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 deletion affected multiple rows') + + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 +} + /** * 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/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..c1aed407dca 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -121,10 +121,7 @@ export async function handleReconnectCredential(params: { .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId @@ -144,12 +141,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..9eb88bc1953 --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { processCredentialDraft } from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..fc3637845f0 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -10,32 +10,54 @@ import { const logger = createLogger('CredentialDraftProcessor') interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params - const [draft] = await db + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } + + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts new file mode 100644 index 00000000000..3dd068a1bed --- /dev/null +++ b/apps/sim/lib/credentials/oauth-draft-state.ts @@ -0,0 +1 @@ +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 6120e5bec76..ecda5d7d249 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' @@ -82,7 +82,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -96,6 +96,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 { @@ -191,14 +193,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') } @@ -334,7 +339,7 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { @@ -486,37 +491,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}`, { @@ -572,6 +547,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 f0dabc6795b..31afb8701d0 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/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..28a3a15595f --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ draftId: 'draft-1' }) + expect(parse(second)).toEqual({ draftId: 'draft-2' }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow( + 'Shopify OAuth state is expired' + ) + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..23d5f29d761 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,102 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 +const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return decoded.draftId ? { draftId: decoded.draftId } : {} +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ba484e5faf6..c91d8b0d3e7 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: 1106, - zodRoutes: 1106, + totalRoutes: 1109, + zodRoutes: 1109, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..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', 22], + ['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(135) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => {