From b64272150d4917915e0c847539f721d7733bf04e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 22:33:56 -0700 Subject: [PATCH 1/6] feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials --- .../content/docs/de/api-reference/meta.json | 5 + .../content/docs/en/api-reference/meta.json | 5 + .../content/docs/es/api-reference/meta.json | 5 + .../content/docs/fr/api-reference/meta.json | 5 + .../content/docs/ja/api-reference/meta.json | 5 + .../content/docs/zh/api-reference/meta.json | 5 + apps/docs/lib/openapi.ts | 1 + apps/docs/openapi-v2-resources.json | 2736 +++++++++++++++++ apps/sim/app/api/credentials/route.ts | 654 +--- apps/sim/app/api/skills/route.ts | 187 +- apps/sim/app/api/v1/middleware.ts | 10 + .../app/api/v2/credentials/[id]/route.test.ts | 320 ++ apps/sim/app/api/v2/credentials/[id]/route.ts | 175 ++ apps/sim/app/api/v2/credentials/route.test.ts | 310 ++ apps/sim/app/api/v2/credentials/route.ts | 140 + apps/sim/app/api/v2/credentials/utils.ts | 75 + .../api/v2/custom-tools/[id]/route.test.ts | 306 ++ .../sim/app/api/v2/custom-tools/[id]/route.ts | 202 ++ .../sim/app/api/v2/custom-tools/route.test.ts | 244 ++ apps/sim/app/api/v2/custom-tools/route.ts | 133 + apps/sim/app/api/v2/custom-tools/utils.ts | 21 + .../sim/app/api/v2/folders/[id]/route.test.ts | 383 +++ apps/sim/app/api/v2/folders/[id]/route.ts | 209 ++ apps/sim/app/api/v2/folders/route.test.ts | 278 ++ apps/sim/app/api/v2/folders/route.ts | 120 + apps/sim/app/api/v2/folders/utils.ts | 60 + .../app/api/v2/mcp-servers/[id]/route.test.ts | 324 ++ apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 163 + apps/sim/app/api/v2/mcp-servers/route.test.ts | 316 ++ apps/sim/app/api/v2/mcp-servers/route.ts | 156 + apps/sim/app/api/v2/mcp-servers/utils.ts | 50 + apps/sim/app/api/v2/skills/[id]/route.test.ts | 311 ++ apps/sim/app/api/v2/skills/[id]/route.ts | 161 + apps/sim/app/api/v2/skills/route.test.ts | 261 ++ apps/sim/app/api/v2/skills/route.ts | 116 + apps/sim/app/api/v2/skills/utils.ts | 48 + apps/sim/lib/api/contracts/skills.ts | 6 +- apps/sim/lib/api/contracts/v2/credentials.ts | 229 ++ apps/sim/lib/api/contracts/v2/custom-tools.ts | 148 + apps/sim/lib/api/contracts/v2/folders.ts | 178 ++ apps/sim/lib/api/contracts/v2/mcp-servers.ts | 217 ++ apps/sim/lib/api/contracts/v2/skills.ts | 156 + .../tools/handlers/management/manage-skill.ts | 150 +- .../orchestration/credential-create.ts | 571 ++++ .../lib/credentials/orchestration/index.ts | 7 + apps/sim/lib/credentials/queries.ts | 114 + apps/sim/lib/folders/queries.ts | 28 + apps/sim/lib/mcp/queries.ts | 59 + apps/sim/lib/posthog/events.ts | 6 +- apps/sim/lib/skills/orchestration/index.ts | 12 + .../skills/orchestration/skill-lifecycle.ts | 347 +++ .../lib/workflows/custom-tools/operations.ts | 51 + scripts/check-api-validation-contracts.ts | 4 +- scripts/check-openapi-specs.ts | 1 + 54 files changed, 9952 insertions(+), 832 deletions(-) create mode 100644 apps/docs/openapi-v2-resources.json create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/route.ts create mode 100644 apps/sim/app/api/v2/credentials/utils.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/utils.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.ts create mode 100644 apps/sim/app/api/v2/folders/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/route.ts create mode 100644 apps/sim/app/api/v2/folders/utils.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/utils.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.ts create mode 100644 apps/sim/app/api/v2/skills/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/route.ts create mode 100644 apps/sim/app/api/v2/skills/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/credentials.ts create mode 100644 apps/sim/lib/api/contracts/v2/custom-tools.ts create mode 100644 apps/sim/lib/api/contracts/v2/folders.ts create mode 100644 apps/sim/lib/api/contracts/v2/mcp-servers.ts create mode 100644 apps/sim/lib/api/contracts/v2/skills.ts create mode 100644 apps/sim/lib/credentials/orchestration/credential-create.ts create mode 100644 apps/sim/lib/credentials/queries.ts create mode 100644 apps/sim/lib/mcp/queries.ts create mode 100644 apps/sim/lib/skills/orchestration/index.ts create mode 100644 apps/sim/lib/skills/orchestration/skill-lifecycle.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index 41f0687139a..c3dac25a837 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -9,6 +9,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] as const export const openapi = createOpenAPI({ diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json new file mode 100644 index 00000000000..56ecc75a8d9 --- /dev/null +++ b/apps/docs/openapi-v2-resources.json @@ -0,0 +1,2736 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workspace Resources", + "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, and credentials.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Fields that carry secret material (MCP request headers, credential values) are accepted on write and never returned on read. Reads expose only whether a secret is configured, and for headers their names.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "MCP Servers", + "description": "Register and manage the Model Context Protocol servers a workspace connects to (v2 API)." + }, + { + "name": "Skills", + "description": "Create and manage the reusable instruction documents agents can be given (v2 API)." + }, + { + "name": "Custom Tools", + "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)." + }, + { + "name": "Folders", + "description": "Organize workflows, knowledge bases, and tables into folder trees (v2 API)." + }, + { + "name": "Credentials", + "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/mcp-servers": { + "get": { + "operationId": "listMcpServers", + "summary": "List MCP Servers", + "description": "List the MCP servers registered in a workspace. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`; treat the response as a standard cursor list so pagination can be added later without a contract change.\n\nConfigured request header **values** are never returned — use `hasHeaders` and `headerNames` to see which headers are set.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "MCP servers registered in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The MCP servers registered in the workspace.", + "items": { "$ref": "#/components/schemas/McpServer" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2025-06-20T14:02:11.000Z", + "lastConnected": "2025-06-20T14:02:11.000Z", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createMcpServer", + "summary": "Create MCP Server", + "description": "Register a new MCP server in a workspace. Requires `write` permission on the workspace.\n\nA server's identity is derived from its URL, so registering a URL that is already registered returns `409 CONFLICT` rather than overwriting the existing server — use `PATCH /api/v2/mcp-servers/{id}` to change one.\n\nThe `url` must be an absolute `http`/`https` URL and may not contain `{{ENV_VAR}}` references: templated hostnames defer domain-allowlist and SSRF checks to call time, which is not safe to accept over an API key.\n\n`headers` and `oauthClientSecret` are write-only and are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/mcp-servers\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Docs server\",\n \"url\": \"https://mcp.example.com/sse\",\n \"headers\": { \"Authorization\": \"Bearer YOUR_TOKEN\" }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The MCP server to register.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateMcpServerBody" }, + "examples": { + "headerAuth": { + "summary": "Header-authenticated server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "description": "Internal documentation tools", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { "Authorization": "Bearer YOUR_TOKEN" }, + "timeout": 30000, + "retries": 3 + } + }, + "oauth": { + "summary": "OAuth server with pre-registered client credentials", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Partner server", + "url": "https://mcp.partner.example.com/mcp", + "authType": "oauth", + "oauthClientId": "sim-client", + "oauthClientSecret": "YOUR_CLIENT_SECRET" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The MCP server was registered.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/mcp-servers/{id}": { + "get": { + "operationId": "getMcpServer", + "summary": "Get MCP Server", + "description": "Fetch a single MCP server by id. Configured request header values and the OAuth client secret are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "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" } + } + }, + "patch": { + "operationId": "updateMcpServer", + "summary": "Update MCP Server", + "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\nChanging `url`, the auth type, or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/McpServerId" }], + "requestBody": { + "required": true, + "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateMcpServerBody" }, + "examples": { + "disable": { + "summary": "Disable a server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + }, + "rotateHeaders": { + "summary": "Rotate the auth header", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "headers": { "Authorization": "Bearer NEW_TOKEN" } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "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" } + } + }, + "delete": { + "operationId": "deleteMcpServer", + "summary": "Delete MCP Server", + "description": "Remove an MCP server from the workspace and revoke any OAuth tokens issued for it. Requires `write` permission on the workspace. Workflows that referenced the server's tools keep their blocks but can no longer call it.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "mcp-3f7a9c21", "deleted": true } } + } + } + }, + "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" } + } + } + }, + "/api/v2/skills": { + "get": { + "operationId": "listSkills", + "summary": "List Skills", + "description": "List the skills available in a workspace. Built-in template skills that ship with Sim are included and are marked `readOnly: true`.\n\nSkill bodies can be up to 50 000 characters, so the list returns summaries only — fetch `GET /api/v2/skills/{id}` for a skill's `content`. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "Skills available in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The skills available in the workspace, without their bodies.", + "items": { "$ref": "#/components/schemas/SkillSummary" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "deploy-workflow", + "name": "deploy-workflow", + "description": "How to deploy a finished workflow", + "readOnly": true, + "createdAt": "1970-01-01T00:00:00.000Z", + "updatedAt": "1970-01-01T00:00:00.000Z" + }, + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createSkill", + "summary": "Create Skill", + "description": "Create a skill in a workspace. Requires `write` permission on the workspace, and the creator becomes an editor of the new skill.\n\n`name` must be kebab-case and unique in the workspace; names reserved by built-in skills are rejected. Unlike the internal endpoint this creates exactly one skill and answers with it, not with the whole workspace list.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/skills\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"refund-policy\",\n \"description\": \"How support should handle refund requests\",\n \"content\": \"# Refund policy\\n\\nAlways check the order date first.\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The skill to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateSkillBody" }, + "examples": { + "refundPolicy": { + "summary": "A support playbook", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The skill was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/skills/{id}": { + "get": { + "operationId": "getSkill", + "summary": "Get Skill", + "description": "Fetch a single skill by id, including its full `content`. Built-in template skills resolve here too and are marked `readOnly: true`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "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" } + } + }, + "patch": { + "operationId": "updateSkill", + "summary": "Update Skill", + "description": "Update a skill. Only the fields you send are changed, so a partial edit never clobbers a concurrent change to a field you did not send.\n\nRequires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/SkillId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateSkillBody" }, + "examples": { + "editDescription": { + "summary": "Change the description only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + }, + "replaceContent": { + "summary": "Replace the skill body", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "# Refund policy\n\nCheck the order date, then the payment method." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteSkill", + "summary": "Delete Skill", + "description": "Delete a skill from the workspace. Requires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "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" } + } + } + }, + "/api/v2/custom-tools": { + "get": { + "operationId": "listCustomTools", + "summary": "List Custom Tools", + "description": "List the custom tools defined in a workspace. Custom tools are code-backed functions agents can call, declared with an OpenAI-style function schema.\n\nOnly workspace tools are returned — legacy personal tools, which predate workspace scoping and belong to a single user, are not part of the public API. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "Custom tools defined in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The custom tools defined in the workspace.", + "items": { "$ref": "#/components/schemas/CustomTool" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "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": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCustomTool", + "summary": "Create Custom Tool", + "description": "Create a custom tool in a workspace. Requires `write` permission on the workspace.\n\n`title` must be unique within the workspace — tools resolve by title at call time, so a duplicate returns `409 CONFLICT`. `code` is the tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/custom-tools\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"title\": \"lookup_order\",\n \"schema\": {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"lookup_order\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": { \"orderId\": { \"type\": \"string\" } },\n \"required\": [\"orderId\"]\n }\n }\n },\n \"code\": \"return { ok: true }\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The custom tool to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCustomToolBody" }, + "examples": { + "lookupOrder": { + "summary": "A tool that calls an internal API", + "value": { + "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": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The custom tool was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/custom-tools/{id}": { + "get": { + "operationId": "getCustomTool", + "summary": "Get Custom Tool", + "description": "Fetch a single custom tool by id, scoped to the workspace.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "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" } + } + }, + "patch": { + "operationId": "updateCustomTool", + "summary": "Update Custom Tool", + "description": "Update a custom tool. Only the fields you send are changed; omitted fields keep their stored values. Requires `write` permission on the workspace.\n\nRenaming onto a title another tool already uses returns `409 CONFLICT`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/CustomToolId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCustomToolBody" }, + "examples": { + "editCode": { + "summary": "Replace the implementation only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + }, + "rename": { + "summary": "Rename the tool", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "find_order" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteCustomTool", + "summary": "Delete Custom Tool", + "description": "Delete a custom tool from the workspace. Requires `write` permission on the workspace. Agent blocks that referenced the tool keep their configuration but can no longer call it.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "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" } + } + } + }, + "/api/v2/folders": { + "get": { + "operationId": "listFolders", + "summary": "List Folders", + "description": "List a workspace's folder tree for one resource type. One folder engine serves several trees, so `resourceType` is **required** — it selects which tree you are addressing.\n\nPass `scope=archived` to list folders in Recently Deleted instead of live ones. A workspace's tree for one resource type is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`. Folders come back in tree order (`sortOrder`, then creation time); build the hierarchy from `parentId`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", + "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + } + ], + "responses": { + "200": { + "description": "Folders in the workspace's tree for the requested resource type.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The folders in the requested tree.", + "items": { "$ref": "#/components/schemas/Folder" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createFolder", + "summary": "Create Folder", + "description": "Create a folder in one of a workspace's resource trees. Requires `write` permission on the workspace.\n\n`resourceType` is required and selects the tree. Pass `parentId: null` (or omit it) to create the folder at the root; a `parentId` must name a live folder of the same `resourceType` in the same workspace.\n\nA sibling folder with the same name returns `409 CONFLICT`. If the parent is a locked workflow folder, the request returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/folders\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Onboarding\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The folder to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateFolderBody" }, + "examples": { + "rootFolder": { + "summary": "A workflow folder at the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Onboarding" + } + }, + "nestedKnowledgeFolder": { + "summary": "A knowledge-base folder nested under another", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "knowledge_base", + "name": "Policies", + "parentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The folder was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/folders/{id}": { + "get": { + "operationId": "getFolder", + "summary": "Get Folder", + "description": "Fetch a single folder by id. Archived folders resolve too — check `deletedAt` to tell them apart from live ones.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The folder.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + } + } + } + } + }, + "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" } + } + }, + "patch": { + "operationId": "updateFolder", + "summary": "Update Folder", + "description": "Rename, move, or reorder a folder. Only the fields you send are changed.\n\nMoving is `parentId` — pass `null` to move to the root. A move that would place a folder inside its own subtree is rejected.\n\n`locked` applies to workflow folders only and requires workspace `admin`; sending it for another tree returns `400`. Everything else needs workspace `write`.\n\nArchived folders cannot be updated (`404`), and a mutation lock anywhere on the path returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Customer Onboarding\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/FolderId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateFolderBody" }, + "examples": { + "rename": { + "summary": "Rename a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Customer Onboarding" + } + }, + "moveToRoot": { + "summary": "Move a folder to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "parentId": null + } + }, + "lock": { + "summary": "Lock a workflow folder (requires admin)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "locked": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated folder.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Customer Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "deletedAt": null + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteFolder", + "summary": "Delete Folder", + "description": "Archive a folder and everything under it. The cascade moves the subtree — subfolders and the resources filed in them — into Recently Deleted, and `deletedItems` reports how much was archived; only the count matching `resourceType` is populated.\n\nDeleting is idempotent: re-issuing it against an already archived folder retries the cascade onto the same snapshot rather than 404ing, so a run that failed partway can be completed.\n\nA mutation lock anywhere in the subtree returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The folder and its subtree were archived.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderDeleteAcknowledgement" }, + "example": { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true, + "deletedItems": { "folders": 3, "workflows": 12 } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/credentials": { + "get": { + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "type", + "in": "query", + "required": false, + "description": "Only return credentials of this kind.", + "schema": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"] + } + }, + { + "name": "providerId", + "in": "query", + "required": false, + "description": "Only return credentials for this integration.", + "schema": { "type": "string", "minLength": 1, "example": "slack" } + } + ], + "responses": { + "200": { + "description": "Credentials visible to the caller in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The credentials visible to the caller.", + "items": { "$ref": "#/components/schemas/Credential" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCredential", + "summary": "Create Credential", + "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The credential to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCredentialBody" }, + "examples": { + "workspaceEnvVar": { + "summary": "A workspace-wide environment secret", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "env_workspace", + "envKey": "STRIPE_API_KEY" + } + }, + "clientCredentialServiceAccount": { + "summary": "A client-credentials service account", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The credential exists with this source. Returned whether it was inserted now or already present.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "env_workspace", + "displayName": "STRIPE_API_KEY", + "description": null, + "providerId": null, + "accountId": null, + "envKey": "STRIPE_API_KEY", + "hasServiceAccountKey": false, + "role": "admin", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } + } + } + }, + "/api/v2/credentials/{id}": { + "get": { + "operationId": "getCredential", + "summary": "Get Credential", + "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CredentialId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The credential.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "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" } + } + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/CredentialId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one field besides `workspaceId` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCredentialBody" }, + "examples": { + "rename": { + "summary": "Rename a credential", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "displayName": "Zoom (production)" + } + }, + "rotateSecret": { + "summary": "Rotate an API token", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "apiToken": "YOUR_NEW_TOKEN" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated credential.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom (production)", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteCredential", + "summary": "Delete Credential", + "description": "Delete a credential. Requires credential admin. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CredentialId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The credential was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { + "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true } + } + } + } + }, + "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" } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { "type": "integer", "example": 60 } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { "type": "integer", "example": 59 } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { "type": "string", "format": "date-time", "example": "2025-06-20T14:16:00Z" } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { "type": "integer", "example": 30 } + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "McpServerId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the MCP server.", + "schema": { "type": "string", "minLength": 1, "example": "mcp-3f7a9c21" } + }, + "SkillId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the skill. Built-in skills use their name as their id.", + "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + }, + "CustomToolId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the custom tool.", + "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + }, + "FolderId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the folder.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "CredentialId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the credential.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "FolderResourceTypeQuery": { + "name": "resourceType", + "in": "query", + "required": true, + "description": "Which resource tree the folder belongs to. Required — folder ids are unique, but addressing the wrong tree would file a folder where its page can never see it.", + "schema": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "example": "workflow" + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Workspace ID is required", + "details": [{ "path": "workspaceId", "message": "Workspace ID is required" }] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "FORBIDDEN", "message": "Access denied" } } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "NOT_FOUND", "message": "MCP server not found" } } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "CONFLICT", + "message": "An MCP server with this URL already exists in this workspace." + } + } + } + } + }, + "Locked": { + "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "LOCKED", + "message": "This folder is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { "$ref": "#/components/headers/RetryAfter" }, + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { "retryAfter": "2025-06-20T14:16:00Z" } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "INTERNAL_ERROR", "message": "Internal server error" } } + } + } + }, + "ServiceUnavailable": { + "description": "An upstream provider could not be reached to verify the request. Retry shortly.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "The credential provider is unavailable. Try again." + } + } + } + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + }, + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "DeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a resource was deleted.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the resource that was deleted." + }, + "deleted": { "type": "boolean", "const": true } + } + } + } + }, + "McpServer": { + "type": "object", + "description": "An MCP server registered in a workspace. Request header values and the OAuth client secret are write-only and never appear here.", + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], + "properties": { + "id": { + "type": "string", + "description": "The server's unique identifier, derived from the workspace and the server URL." + }, + "name": { "type": "string", "description": "Display name of the server." }, + "description": { "type": "string", "description": "Optional description." }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim authenticates to the server." + }, + "url": { "type": "string", "description": "The server's endpoint URL." }, + "timeout": { + "type": "number", + "description": "Per-request timeout in milliseconds." + }, + "retries": { "type": "number", "description": "Number of retries per request." }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows." + }, + "connectionStatus": { + "type": "string", + "enum": ["connected", "disconnected", "error"], + "description": "Result of the most recent connection attempt." + }, + "lastError": { + "type": ["string", "null"], + "description": "Message from the most recent failed connection, if any." + }, + "toolCount": { + "type": "number", + "description": "Number of tools discovered on the server." + }, + "lastToolsRefresh": { + "type": "string", + "format": "date-time", + "description": "When the server's tool list was last refreshed." + }, + "lastConnected": { + "type": "string", + "format": "date-time", + "description": "When Sim last connected successfully." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "oauthClientId": { + "type": "string", + "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration." + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured. Values are never returned." + }, + "headerNames": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of the configured request headers. Values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored for this server." + } + } + }, + "McpServerData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["mcpServer"], + "properties": { "mcpServer": { "$ref": "#/components/schemas/McpServer" } } + } + } + }, + "CreateMcpServerBody": { + "type": "object", + "description": "A new MCP server registration.", + "additionalProperties": false, + "required": ["workspaceId", "name", "url"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to register the server in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name of the server." + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional description." + }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server. Defaults to `streamable-http`." + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim should authenticate. Detected from the server when omitted." + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read." + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000, + "description": "Per-request timeout in milliseconds. Defaults to 30000." + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "description": "Number of retries per request. Defaults to 3." + }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows. Defaults to true." + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512, + "description": "Pre-registered OAuth client id for servers without dynamic client registration." + }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Pre-registered OAuth client secret. Never returned on read." + } + } + }, + "UpdateMcpServerBody": { + "type": "object", + "description": "Fields to change on an existing MCP server. Omitted fields are left as they are.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the server." + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "description": { "type": "string", "maxLength": 2000 }, + "transport": { "type": "string", "enum": ["streamable-http"] }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references." + }, + "authType": { "type": "string", "enum": ["none", "headers", "oauth"] }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Write-only. Replaces the stored header map wholesale." + }, + "timeout": { "type": "integer", "minimum": 1000, "maximum": 300000 }, + "retries": { "type": "integer", "minimum": 0, "maximum": 10 }, + "enabled": { "type": "boolean" }, + "oauthClientId": { "type": ["string", "null"], "maxLength": 512 }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Never returned on read." + } + } + }, + "SkillSummary": { + "type": "object", + "description": "A skill without its body. Fetch the skill by id to read `content`.", + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The skill's unique identifier." }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "Skill": { + "type": "object", + "description": "A skill, including its full body.", + "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The skill's unique identifier." }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "description": "The skill body — the instructions handed to the agent." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "SkillData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["skill"], + "properties": { "skill": { "$ref": "#/components/schemas/Skill" } } + } + } + }, + "CreateSkillBody": { + "type": "object", + "description": "A new skill.", + "additionalProperties": false, + "required": ["workspaceId", "name", "description", "content"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the skill in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace. Names reserved by built-in skills are rejected." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The skill body — the instructions handed to the agent." + } + } + }, + "UpdateSkillBody": { + "type": "object", + "description": "Fields to change on an existing skill. At least one of `name`, `description`, or `content` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the skill." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "content": { "type": "string", "minLength": 1, "maxLength": 50000 } + } + }, + "CustomToolSchema": { + "type": "object", + "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.", + "required": ["type", "function"], + "properties": { + "type": { "type": "string", "const": "function" }, + "function": { + "type": "object", + "required": ["name", "parameters"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The function name the model calls." + }, + "description": { + "type": "string", + "description": "What the tool does, shown to the model." + }, + "parameters": { + "type": "object", + "description": "JSON Schema for the tool's arguments.", + "required": ["type", "properties"], + "properties": { + "type": { "type": "string", "description": "Usually `object`." }, + "properties": { + "type": "object", + "additionalProperties": true, + "description": "Caller-defined argument schemas, keyed by argument name." + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of the required arguments." + } + } + } + } + } + } + }, + "CustomTool": { + "type": "object", + "description": "A code-backed tool defined in a workspace that agents can call.", + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The tool's unique identifier." }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace. Tools also resolve by title at call time." + }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { + "type": "string", + "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "CustomToolData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["customTool"], + "properties": { "customTool": { "$ref": "#/components/schemas/CustomTool" } } + } + } + }, + "CreateCustomToolBody": { + "type": "object", + "description": "A new custom tool.", + "additionalProperties": false, + "required": ["workspaceId", "title", "schema", "code"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the tool in." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "The tool body, executed in Sim's sandboxed function runtime." + } + } + }, + "UpdateCustomToolBody": { + "type": "object", + "description": "Fields to change on an existing custom tool. At least one of `title`, `schema`, or `code` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the tool." + }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { "type": "string", "maxLength": 100000 } + } + }, + "Folder": { + "type": "object", + "description": "A folder in one of a workspace's resource trees.", + "required": [ + "id", + "resourceType", + "name", + "parentId", + "locked", + "sortOrder", + "createdAt", + "updatedAt", + "deletedAt" + ], + "properties": { + "id": { "type": "string", "description": "The folder's unique identifier." }, + "resourceType": { + "type": "string", + "enum": ["workflow", "file", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Only `workflow`, `knowledge_base`, and `table` are served by this API; `file` folders have their own surface." + }, + "name": { "type": "string", "description": "Display name." }, + "parentId": { + "type": ["string", "null"], + "description": "The containing folder, or null when the folder sits at the workspace root." + }, + "locked": { + "type": "boolean", + "description": "Whether the folder is locked against modification. Workflow folders only; always false elsewhere." + }, + "sortOrder": { + "type": "number", + "description": "Position among its siblings, ascending." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "When the folder was archived into Recently Deleted, or null when it is live." + } + } + }, + "FolderData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { "folder": { "$ref": "#/components/schemas/Folder" } } + } + } + }, + "FolderDeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a folder was archived, with what the cascade took with it.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the folder that was archived." + }, + "deleted": { "type": "boolean", "const": true }, + "deletedItems": { + "type": "object", + "description": "How much the cascade archived. Only the count matching the folder's `resourceType` is populated.", + "required": ["folders"], + "properties": { + "folders": { + "type": "integer", + "description": "Subfolders archived, including the folder itself." + }, + "workflows": { "type": "integer" }, + "files": { "type": "integer" }, + "knowledgeBases": { "type": "integer" }, + "tables": { "type": "integer" } + } + } + } + } + } + }, + "CreateFolderBody": { + "type": "object", + "description": "A new folder.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the folder in." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree to create the folder in. Required." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Must be unique among its siblings." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "The containing folder. Omit or pass null to create at the workspace root." + }, + "sortOrder": { + "type": "integer", + "minimum": 0, + "description": "Position among its siblings. Defaults to the top of the list." + } + } + }, + "UpdateFolderBody": { + "type": "object", + "description": "Fields to change on an existing folder. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the folder." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Required." + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "locked": { + "type": "boolean", + "description": "Workflow folders only, and changing it requires workspace `admin`." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "New parent folder. Pass null to move to the workspace root." + }, + "sortOrder": { "type": "integer", "minimum": 0 } + } + }, + "Credential": { + "type": "object", + "description": "A stored credential. Secret material is write-only and never appears here.", + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "envKey", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string", "description": "The credential's unique identifier." }, + "type": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"], + "description": "What kind of credential this is." + }, + "displayName": { "type": "string", "description": "Display name." }, + "description": { "type": ["string", "null"] }, + "providerId": { + "type": ["string", "null"], + "description": "The integration this credential authenticates against, when it has one." + }, + "accountId": { + "type": ["string", "null"], + "description": "The linked OAuth account, for `oauth` credentials." + }, + "envKey": { + "type": ["string", "null"], + "description": "The environment-variable name, for `env_workspace` / `env_personal` credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account secret is stored. The secret itself is never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "The caller's role on this credential. Only admins can update or delete it." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "CredentialData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["credential"], + "properties": { "credential": { "$ref": "#/components/schemas/Credential" } } + } + } + }, + "CreateCredentialBody": { + "type": "object", + "description": "A new credential. Every secret field is write-only and is never returned.", + "additionalProperties": false, + "required": ["workspaceId", "type"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the credential in." + }, + "type": { + "type": "string", + "enum": ["env_workspace", "env_personal", "service_account"], + "description": "`oauth` is not creatable here — use the interactive OAuth connect flow." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Derived from the env key or the verified provider account when omitted." + }, + "description": { "type": "string", "maxLength": 500 }, + "providerId": { + "type": "string", + "minLength": 1, + "description": "Required for `service_account` — the integration the secret belongs to." + }, + "envKey": { + "type": "string", + "minLength": 1, + "description": "Required for env credentials. Letters, numbers, and underscores only; `{{NAME}}` is accepted and unwrapped." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Google-style service-account JSON key." + }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot signing secret." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot token." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Atlassian API token." + }, + "domain": { + "type": "string", + "minLength": 1, + "description": "Atlassian site domain, paired with `apiToken`." + }, + "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only. Client-credentials secret." + }, + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + "UpdateCredentialBody": { + "type": "object", + "description": "Fields to change on an existing credential. At least one field besides `workspaceId` is required. Sending a secret field rotates that secret in place; secrets are never returned.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the credential." + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 255 }, + "description": { + "type": ["string", "null"], + "maxLength": 500, + "description": "Pass null to clear the description." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Replaces the stored service-account JSON key." + }, + "signingSecret": { "type": "string", "minLength": 1, "description": "Write-only." }, + "botToken": { "type": "string", "minLength": 1, "description": "Write-only." }, + "apiToken": { "type": "string", "minLength": 1, "description": "Write-only." }, + "domain": { "type": "string", "minLength": 1 }, + "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only." + }, + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + } + } +} diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 99ba00a6151..c8b1a7c540f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,149 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialMember } from '@sim/db/schema' +import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, credentialsListGetQuerySchema, - normalizeCredentialEnvKey, } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getCredentialActorContext, - isSharedCredentialType, - SHARED_CREDENTIAL_TYPES, -} from '@/lib/credentials/access' -import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' -import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - ServiceAccountSecretError, - verifyAndBuildServiceAccountSecret, -} from '@/lib/credentials/service-account-secret' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { captureServerEvent } from '@/lib/posthog/server' + performCreateCredential, + statusForCredentialOrchestrationError, +} from '@/lib/credentials/orchestration/credential-create' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialsAPI') -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters[0]>[0], - params: ExistingCredentialSourceParams -) { - return findExistingCredentialBySourceWith(tx, params) -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -222,56 +99,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) } - const whereClauses = [eq(credential.workspaceId, workspaceId)] - - if (type) { - whereClauses.push(eq(credential.type, type)) - } - if (providerId) { - whereClauses.push(eq(credential.providerId, providerId)) - } - - const isWorkspaceAdmin = workspaceAccess.canAdmin - const accessClause = isWorkspaceAdmin - ? or( - isNotNull(credentialMember.id), - inArray(credential.type, SHARED_CREDENTIAL_TYPES), - eq(credential.envOwnerUserId, session.user.id) - ) - : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id)) - - const rows = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - description: credential.description, - providerId: credential.providerId, - accountId: credential.accountId, - envKey: credential.envKey, - envOwnerUserId: credential.envOwnerUserId, - createdBy: credential.createdBy, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - memberRole: credentialMember.role, - }) - .from(credential) - .leftJoin( - credentialMember, - and( - eq(credentialMember.credentialId, credential.id), - eq(credentialMember.userId, session.user.id), - eq(credentialMember.status, 'active') - ) - ) - .where(and(...whereClauses, accessClause)) - - const credentials = rows.map(({ memberRole, ...rest }) => ({ - ...rest, - role: - isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), - })) + const visible = await listVisibleWorkspaceCredentials({ + workspaceId, + userId: session.user.id, + workspaceAccess, + type, + providerId, + }) + const credentials = visible.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) return NextResponse.json({ credentials }) } catch (error) { @@ -288,433 +123,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - try { - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { - workspaceId, - type, - displayName, - description, - providerId, - accountId, - envKey, - envOwnerUserId, - serviceAccountJson, - apiToken, - domain, - id: clientCredentialId, - signingSecret, - botToken, - clientId, - clientSecret, - orgId, - } = parsed.data.body - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - let resolvedDisplayName = displayName?.trim() ?? '' - const resolvedDescription = description?.trim() || null - let resolvedProviderId: string | null = providerId ?? null - let resolvedAccountId: string | null = accountId ?? null - const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null - let resolvedEnvOwnerUserId: string | null = null - let resolvedEncryptedServiceAccountKey: string | null = null - const extraAuditMetadata: Record = {} - - if (type === 'oauth') { - const [accountRow] = await db - .select({ - id: account.id, - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where(eq(account.id, accountId!)) - .limit(1) - - if (!accountRow) { - return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 }) - } - - if (accountRow.userId !== session.user.id) { - return NextResponse.json( - { error: 'Only account owners can create oauth credentials for an account' }, - { status: 403 } - ) - } - - if (providerId !== accountRow.providerId) { - return NextResponse.json( - { error: 'providerId does not match the selected OAuth account' }, - { status: 400 } - ) - } - if (!resolvedDisplayName) { - resolvedDisplayName = - getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId - } - } else if (type === 'service_account') { - try { - const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', { - signingSecret, - botToken, - apiToken, - domain, - serviceAccountJson, - clientId, - clientSecret, - orgId, - }) - resolvedProviderId = secret.providerId - resolvedAccountId = null - resolvedEnvOwnerUserId = null - if (!resolvedDisplayName) { - resolvedDisplayName = secret.displayName - } - resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey - Object.assign(extraAuditMetadata, secret.auditMetadata) - } catch (error) { - if (error instanceof ServiceAccountSecretError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (type === 'env_personal') { - resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id - if (resolvedEnvOwnerUserId !== session.user.id) { - return NextResponse.json( - { error: 'Only the current user can create personal env credentials for themselves' }, - { status: 403 } - ) - } - resolvedProviderId = null - resolvedAccountId = null - resolvedDisplayName = resolvedEnvKey || '' - } else { - resolvedProviderId = null - resolvedAccountId = null - resolvedEnvOwnerUserId = null - resolvedDisplayName = resolvedEnvKey || '' - } - - if (!resolvedDisplayName) { - return NextResponse.json({ error: 'Display name is required' }, { status: 400 }) - } - - const existingCredential = await findExistingCredentialBySource({ - workspaceId, - type, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, + const parsed = await parseRequest( + createWorkspaceCredentialContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const result = await performCreateCredential({ + ...parsed.data.body, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success) { + logger.warn(`[${requestId}] Credential create rejected`, { + errorCode: result.errorCode, + providerErrorCode: result.providerErrorCode, }) - - if (existingCredential) { - // A retried custom-bot create with the SAME pre-generated id is an - // idempotent replay and falls through to the normal existing-credential - // path. Any other name collision must fail loudly: returning the existing - // row as success would orphan the new id already embedded in the user's - // Slack Request URL (Slack would post to a URL no credential resolves). - if ( - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && - clientCredentialId && - existingCredential.id !== clientCredentialId - ) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, - }, - { status: 409 } - ) - } - - // Token service-account creates always carry a fresh token that must be - // stored — falling through to the existing-credential path would return - // the old credential as success and silently drop the submitted token. - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, - }, - { status: 409 } - ) - } - - const access = await getCredentialActorContext(existingCredential.id, session.user.id, { - workspaceAccess, - }) - - if (!access.member && !access.isAdmin) { - return NextResponse.json( - { error: 'A credential with this source already exists in this workspace' }, - { status: 409 } - ) - } - - const canUpdateExistingCredential = access.isAdmin - const shouldUpdateDisplayName = - type === 'oauth' && - resolvedDisplayName && - resolvedDisplayName !== existingCredential.displayName - const shouldUpdateDescription = - typeof description !== 'undefined' && - (existingCredential.description ?? null) !== resolvedDescription - - if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) { - await db - .update(credential) - .set({ - ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), - ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), - updatedAt: new Date(), - }) - .where(eq(credential.id, existingCredential.id)) - - const [updatedCredential] = await db - .select() - .from(credential) - .where(eq(credential.id, existingCredential.id)) - .limit(1) - - return NextResponse.json( - { credential: updatedCredential ?? existingCredential }, - { status: 200 } - ) - } - - return NextResponse.json({ credential: existingCredential }, { status: 200 }) - } - - const now = new Date() - // Honor a client-supplied id only for custom Slack bots — the setup modal - // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist. - const credentialId = - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId - ? clientCredentialId - : generateId() - - const creationResult = await db.transaction(async (tx) => { - /** - * Discover the organization lock scope inside this transaction, then - * acquire the same organization → user → membership locks as org - * removal/transfer and re-authorize from the transaction before writing. - * - * If this insert wins, transfer sees the new source-owned personal - * credential and blocks. If transfer wins, its permission/member cleanup - * is visible to the authoritative re-read below and the insert is - * refused. - */ - const plannedContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - }) - if (!plannedContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - await acquireOrganizationUserMutationLocks(tx, { - userId: session.user.id, - organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], - }) - - const currentContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - forUpdate: true, - }) - if (!currentContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - if (currentContext.organizationId !== plannedContext.organizationId) { - return { - success: false as const, - status: 409 as const, - error: 'Workspace organization changed while creating the credential. Please retry.', - } - } - if (!currentContext.canWrite) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - // service_account has no DB-level unique index on (workspaceId, providerId, - // displayName), so we re-check inside the tx. OAuth/env_* are guarded by - // partial unique indexes and fall through to the 23505 handler below. - if (type === 'service_account') { - const innerExisting = await findExistingCredentialBySourceTx(tx, { - workspaceId, - type, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, - }) - if (innerExisting) throw new DuplicateCredentialError() - } - - await tx.insert(credential).values({ - id: credentialId, - workspaceId, - type, - displayName: resolvedDisplayName, - description: resolvedDescription, - providerId: resolvedProviderId, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { - if (currentContext.memberUserIds.length > 0) { - for (const memberUserId of currentContext.memberUserIds) { - const isAdmin = memberUserId === session.user.id - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: memberUserId, - role: isAdmin ? 'admin' : 'member', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - } - } else { - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: session.user.id, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - - return { success: true as const } + const status = statusForCredentialOrchestrationError(result.errorCode, { + providerUnavailable: result.providerUnavailable, }) - if (!creationResult.success) { - return NextResponse.json({ error: creationResult.error }, { status: creationResult.status }) - } - - const [created] = await db - .select() - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - captureServerEvent( - session.user.id, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } + return NextResponse.json( + result.providerErrorCode + ? { code: result.providerErrorCode, error: result.error } + : { error: result.error }, + { status } ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - credentialType: type, - providerId: resolvedProviderId, - ...extraAuditMetadata, - }, - request, - }) - - return NextResponse.json({ credential: created }, { status: 201 }) - } catch (error: unknown) { - if (error instanceof AtlassianValidationError) { - logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) - } - if (error instanceof TokenServiceAccountValidationError) { - logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - // A provider outage is an infra failure, not a bad request — mirror the - // runtime token route so monitoring sees a 502, not a 400. - const status = error.code === 'provider_unavailable' ? 502 : 400 - return NextResponse.json({ code: error.code, error: error.code }, { status }) - } - if (error instanceof DuplicateCredentialError) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: 'A credential with that name already exists in this workspace.', - }, - { status: 409 } - ) - } - const pgCode = getPostgresErrorCode(error) - if (pgCode === '23505') { - return NextResponse.json( - { error: 'A credential with this source already exists' }, - { status: 409 } - ) - } - if (pgCode === '23503') { - return NextResponse.json( - { error: 'Invalid credential reference or membership target' }, - { status: 400 } - ) - } - if (pgCode === '23514') { - return NextResponse.json( - { error: 'Credential source data failed validation checks' }, - { status: 400 } - ) - } - const errAsRecord = - typeof error === 'object' && error !== null ? (error as Record) : {} - logger.error(`[${requestId}] Credential create failure details`, { - code: pgCode, - detail: errAsRecord.detail, - constraint: errAsRecord.constraint, - table: errAsRecord.table, - message: errAsRecord.message, - }) - logger.error(`[${requestId}] Failed to create credential`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + // An existing credential matched the source: an idempotent replay, not a create. + return NextResponse.json( + { credential: result.credential }, + { status: result.created ? 201 : 200 } + ) }) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index f31f0881e71..251635a37fb 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,10 +9,14 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + statusForSkillOrchestrationError, +} from '@/lib/skills/orchestration' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -92,84 +95,75 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { - return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + /** + * Each item is applied through the skill orchestration, which owns the + * built-in guard, the field limits, the per-skill editor check, and the + * audit. Creating still requires workspace write; editing an existing skill + * is gated per skill inside `performUpdateSkill`. + * + * The batch is applied item by item rather than in one transaction: this + * endpoint's callers submit a single skill, and one shared authority for the + * rules is worth more than atomicity across a batch nobody sends. + */ + const actor = { + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request: req, } - // Updating an existing skill requires editor access (explicit editor row - // or derived workspace admin); creating a new one requires workspace write. - const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) - const { existingIds, denied } = await checkSkillsUpdateAccess({ - workspaceId, - userId, - skillIds: requestedIds, - workspaceAccess, - }) - - if (denied.length > 0) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { - deniedSkillIds: denied.map((s) => s.id), - }) - return NextResponse.json( - { - error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, - }, - { status: 403 } - ) - } + for (const item of skills) { + if (item.id) { + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: item.id, + name: item.name, + description: item.description, + content: item.content, + ...actor, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill update rejected`, { + skillId: item.id, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to update skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } + continue + } - const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) - if (hasCreates && !workspaceAccess.canWrite) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } + if (!workspaceAccess.canWrite) { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } - try { - const { touched } = await upsertSkills({ - skills, + const result = await performCreateSkill({ workspaceId, userId, - requestId, - returnSkills: false, + name: item.name!, + description: item.description!, + content: item.content!, + ...actor, }) - - for (const { id, name, operation } of touched) { - const isUpdate = operation === 'updated' - recordAudit({ - workspaceId, - actorId: userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: isUpdate ? AuditAction.SKILL_UPDATED : AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: id, - resourceName: name, - description: `${isUpdate ? 'Updated' : 'Created'} skill "${name}"`, - metadata: { source }, - }) - captureServerEvent( - userId, - isUpdate ? 'skill_updated' : 'skill_created', - { skill_id: id, skill_name: name, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } + if (!result.success) { + logger.warn(`[${requestId}] Skill create rejected`, { errorCode: result.errorCode }) + return NextResponse.json( + { error: result.error ?? 'Failed to create skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } ) } + } - const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) - const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) - return NextResponse.json({ success: true, data }) - } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { - return NextResponse.json({ error: upsertError.message }, { status: 409 }) - } - if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - throw upsertError - } + return NextResponse.json({ success: true, data }) } catch (error) { logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) @@ -200,42 +194,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - if (!isBuiltinSkillId(skillId)) { - const actor = await getSkillActorContext(skillId, userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - if (!actor.canEdit) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) - return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) - } - } - - const deleted = await deleteSkill({ skillId, workspaceId }) - if (!deleted) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: authResult.userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: `Deleted skill`, - metadata: { source }, - }) - - captureServerEvent( userId, - 'skill_deleted', - { skill_id: skillId, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } - ) + skillId, + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill delete rejected`, { + skillId, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to delete skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 1eb3d86acfe..97f7372f7a6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -45,6 +45,16 @@ export type ApiEndpoint = | 'knowledge-search' | 'copilot-chat' | 'billing-usage' + | 'mcp-servers' + | 'mcp-server-detail' + | 'skills' + | 'skill-detail' + | 'custom-tools' + | 'custom-tool-detail' + | 'folders' + | 'folder-detail' + | 'credentials' + | 'credential-detail' export interface RateLimitResult { allowed: boolean diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts new file mode 100644 index 00000000000..2d6799509cd --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -0,0 +1,320 @@ +/** + * @vitest-environment node + * + * Public v2 credential detail: workspace scoping of the id, the 404 mask for a + * credential the caller has no membership on, and secret-free reads. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCredential, + mockGetCredentialActorContext, + mockPerformUpdateCredential, + mockPerformDeleteCredential, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockPerformUpdateCredential: vi.fn(), + mockPerformDeleteCredential: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mockGetWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performUpdateCredential: mockPerformUpdateCredential, + performDeleteCredential: mockPerformDeleteCredential, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) }) +const url = (query = `workspaceId=${WORKSPACE_ID}`) => + `http://localhost:3000/api/v2/credentials/cred_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks a credential the caller has no membership on as 404', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public shape with no secret material', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.credential).toEqual({ + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + }) +}) + +describe('PATCH /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockPerformUpdateCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('403s when the caller is not a credential admin', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('rotates a secret without echoing it back', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(JSON.stringify(body)).not.toContain('brand-new-token') + expect(mockPerformUpdateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'cred_abc123', + userId: 'user-1', + apiToken: 'brand-new-token', + }) + ) + }) +}) + +describe('DELETE /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockPerformDeleteCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('deletes the credential and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } }) + expect(mockPerformDeleteCredential).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts new file mode 100644 index 00000000000..975e5e9c9fe --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -0,0 +1,175 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCredentialContract, + v2GetCredentialContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const credential = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!credential) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * Workspace access is not credential access: seeing a credential requires a + * membership row (or workspace admin over a shared type). A caller who has + * neither gets 404 rather than 403 so credential existence never leaks to + * someone who cannot use it. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data( + { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...changes } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + // Tenant-scope the id before the orchestration re-derives access from the + // credential's own workspace. + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) + + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to update credential' + ) + } + + const updated = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!updated) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + const result = await performDeleteCredential({ credentialId: id, userId, request }) + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to delete credential' + ) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts new file mode 100644 index 00000000000..c84b4414dfb --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -0,0 +1,310 @@ +/** + * @vitest-environment node + * + * Public v2 credentials list/create: gate ordering, the write-only treatment of + * secret material, and the exclusion of `oauth` from the creatable types. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockListVisibleWorkspaceCredentials, + mockPerformCreateCredential, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), + mockPerformCreateCredential: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mockPerformCreateCredential, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/credentials/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildVisible(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'admin' as const, + ...overrides, + } +} + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + envKey: 'STRIPE_API_KEY', +} + +describe('GET /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(404) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(403) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public credential shape with no secret material', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' }) + ) + }) + + it('passes the type and providerId filters through', async () => { + await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ type: 'oauth', providerId: 'slack' }) + ) + }) +}) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: true, + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is missing for an env credential', async () => { + const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is not a valid environment variable name', async () => { + const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s on an oauth create, which requires the interactive connect flow', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'slack', + accountId: 'acct_1', + displayName: 'Slack', + }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a provider outage to 503 rather than a bad request', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('creates the credential and never echoes the submitted secret', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'super-secret-value', + orgId: 'acct_123', + }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential).toMatchObject({ + id: 'cred_abc123', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('super-secret-value') + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + type: 'service_account', + clientSecret: 'super-secret-value', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts new file mode 100644 index 00000000000..c9ab02f5995 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -0,0 +1,140 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + toV2Credential, + toV2CredentialRow, + v2CredentialOrchestrationError, +} from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCredentialsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, type, providerId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * Credential visibility is per credential, not per workspace: membership + * rows and shared-type admin access decide what this caller sees, so the + * workspace permission is re-read here for the `canAdmin` bit. + */ + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const credentials = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + type, + providerId, + }) + + // The per-workspace credential set is small and bounded → a single full page. + return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing credentials`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/credentials — Create a workspace credential. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCredentialContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateCredential({ ...parsed.data.body, userId, request }) + + if (!result.success || !result.credential) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to create credential', + { providerUnavailable: result.providerUnavailable } + ) + } + + /** + * The creator is always an admin of the credential they just made, whether + * the row was inserted now or matched an existing source. + */ + const credential = toV2CredentialRow(result.credential, 'admin') + + /** + * Always 201, including when an existing credential already occupied this + * source. Create is idempotent on the source tuple, and the caller's + * post-condition — "a credential with this source exists, here it is" — is + * the same either way. + */ + return v2Data({ credential }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..b70903da663 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,75 @@ +import type { NextResponse } from 'next/server' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 credentials surface. + * + * Both projections are written field by field on purpose: a credential row + * carries `encryptedServiceAccountKey`, and spreading the row would put it one + * forgotten `omit` away from the wire. + */ + +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Projection for a raw credential row, whose caller-role is resolved separately. */ +export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey), + role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** + * Renders a credential orchestration failure in the v2 error envelope. + * + * `forbidden` from the orchestration means "not an admin of this credential", + * which is a resource-level denial rather than a workspace one; it stays a 403 + * because the caller already proved workspace access to reach it. + */ +export function v2CredentialOrchestrationError( + errorCode: CredentialOrchestrationErrorCode | undefined, + message: string, + options: { providerUnavailable?: boolean } = {} +): NextResponse { + if (options.providerUnavailable) { + return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.') + } + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Credential not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts new file mode 100644 index 00000000000..22bccd11209 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + * + * Public v2 custom tool detail: the per-id get/update/delete the internal + * surface never had, and the rename guard that keeps a duplicate title from + * reaching the unique index. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCustomTool, + mockGetWorkspaceCustomToolByTitle, + mockDeleteWorkspaceCustomTool, + mockUpsertCustomTools, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCustomTool: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockDeleteWorkspaceCustomTool: vi.fn(), + mockUpsertCustomTools: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + getWorkspaceCustomTool: mockGetWorkspaceCustomTool, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, + upsertCustomTools: mockUpsertCustomTools, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + parameters: { type: 'object', properties: { orderId: { type: 'string' } } }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public tool shape without internal scoping columns', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.customTool).toEqual({ + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) + +describe('PATCH /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpsertCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(403) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('409s when renaming onto an existing title', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' })) + + const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('merges the partial body against the stored tool', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(200) + expect(mockUpsertCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + tools: [ + { + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return 2', + }, + ], + }) + ) + }) +}) + +describe('DELETE /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockDeleteWorkspaceCustomTool.mockResolvedValue(true) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('deletes the tool and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } }) + expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts new file mode 100644 index 00000000000..5ebf1d5ad97 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -0,0 +1,202 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + deleteWorkspaceCustomTool, + getWorkspaceCustomTool, + getWorkspaceCustomToolByTitle, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + } + + await upsertCustomTools({ + tools: [ + { + id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }, + ], + workspaceId, + userId, + requestId, + }) + + const updated = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: id, + resourceName: tool.title, + description: `Deleted custom tool "${tool.title}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts new file mode 100644 index 00000000000..05e3bf45f30 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -0,0 +1,244 @@ +/** + * @vitest-environment node + * + * Public v2 custom tools list/create: gate ordering, contract validation, and + * the workspace-scoped single-resource create that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceCustomTools, + mockGetWorkspaceCustomToolByTitle, + mockUpsertCustomTools, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceCustomTools: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockUpsertCustomTools: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listWorkspaceCustomTools: mockListWorkspaceCustomTools, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + upsertCustomTools: mockUpsertCustomTools, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/custom-tools/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + description: 'Look up an order by id', + parameters: { + type: 'object', + properties: { orderId: { type: 'string' } }, + required: ['orderId'], + }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/custom-tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', +} + +describe('GET /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) +}) + +describe('POST /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpsertCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the schema is not an OpenAI function declaration', async () => { + const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callCreate({ ...VALID_BODY, bogus: true }) + expect(res.status).toBe(400) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate title instead of hitting the unique index', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool()) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('creates the tool and returns 201 with the single tool', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' }) + expect(mockUpsertCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }], + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts new file mode 100644 index 00000000000..e28332c4193 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -0,0 +1,133 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCustomToolContract, + v2ListCustomToolsContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getWorkspaceCustomToolByTitle, + listWorkspaceCustomTools, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/custom-tools — List custom tools in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCustomToolsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceCustomTools({ workspaceId }) + + // The per-workspace tool set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing custom tools`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/custom-tools — Create a custom tool. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCustomToolContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts new file mode 100644 index 00000000000..ccec5df4c1c --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -0,0 +1,21 @@ +import type { customTools } from '@sim/db/schema' +import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' + +/** Shared serialization for the v2 custom tool surface. */ + +type CustomToolRow = typeof customTools.$inferSelect + +/** + * Public custom tool projection. `workspaceId` and `userId` are internal + * scoping columns and are not exposed. + */ +export function toV2CustomTool(row: CustomToolRow): V2CustomTool { + return { + id: row.id, + title: row.title, + schema: row.schema as V2CustomTool['schema'], + code: row.code, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/folders/[id]/route.test.ts b/apps/sim/app/api/v2/folders/[id]/route.test.ts new file mode 100644 index 00000000000..6ab9c1d3957 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.test.ts @@ -0,0 +1,383 @@ +/** + * @vitest-environment node + * + * Public v2 folder detail: the archived-row split between PATCH and DELETE, the + * admin gate on `locked`, and the 423 a mutation lock produces. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockFindActiveFolder, + mockFindFolderInWorkspace, + mockUpdateFolder, + mockDeleteFolder, + mockAssertFolderMutable, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockFindFolderInWorkspace: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + findActiveFolder: mockFindActiveFolder, + findFolderInWorkspace: mockFindFolderInWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + updateFolder: mockUpdateFolder, + deleteFolder: mockDeleteFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/folders/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'fld_abc123' }) }) +const url = (query = 'workspaceId=workspace-1&resourceType=workflow') => + `http://localhost:3000/api/v2/folders/fld_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/folders/fld_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callGet('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public folder shape', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder).toEqual({ + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }) + expect(mockFindFolderInWorkspace).toHaveBeenCalledWith('fld_abc123', 'workspace-1', 'workflow') + }) +}) + +describe('PATCH /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindActiveFolder.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockUpdateFolder.mockResolvedValue({ success: true, folder: buildRow({ name: 'Renamed' }) }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow' }) + expect(res.status).toBe(400) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(403) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('requires only write permission for an ordinary rename', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', name: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('escalates to admin when locked is being set', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', locked: true }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'admin' + ) + }) + + it('400s when locked is sent for a tree that does not support locking', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'table', + locked: true, + }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('workflow folders') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('404s on an archived folder so a locked subtree cannot be edited through it', async () => { + mockFindActiveFolder.mockResolvedValue(null) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the change', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('updates the folder and returns the public shape', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder.name).toBe('Renamed') + expect(mockUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Renamed', + }) + ) + }) +}) + +describe('DELETE /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockDeleteFolder.mockResolvedValue({ + success: true, + deletedItems: { folders: 2, workflows: 5 }, + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callDelete('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the delete', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('deletes the folder and reports the cascade counts', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { id: 'fld_abc123', deleted: true, deletedItems: { folders: 2, workflows: 5 } }, + }) + expect(mockDeleteFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + folderName: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/[id]/route.ts b/apps/sim/app/api/v2/folders/[id]/route.ts new file mode 100644 index 00000000000..b05d090d600 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFolderContract, + v2GetFolderContract, + v2UpdateFolderContract, +} from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { findActiveFolder, findFolderInWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, v2FolderMutationError } from '@/app/api/v2/folders/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FolderDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/folders/[id] — Fetch a single folder, archived or live. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folder = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!folder) return v2Error('NOT_FOUND', 'Folder not found') + + return v2Data({ folder: toV2Folder(folder) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/folders/[id] — Rename, move, reorder, or lock a folder. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType, name, locked, parentId, sortOrder } = parsed.data.body + + /** + * Setting `locked` is an admin capability, matching the UI; every other + * field needs only workspace write. + */ + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workspaceId, + locked === undefined ? 'write' : 'admin' + ) + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived folders are excluded deliberately: `getFolderLockStatus` skips + * archived rows, so an archived-but-locked folder reports unlocked. Without + * this filter, deleting a folder would make every locked subfolder under it + * freely renameable and reparentable. + */ + const existing = await findActiveFolder(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking) + if (locked !== undefined && !supportsLocking) { + return v2Error('BAD_REQUEST', 'Folder locking is only supported for workflow folders') + } + + if (supportsLocking) { + const hasNonLockUpdate = + name !== undefined || parentId !== undefined || sortOrder !== undefined + if (hasNonLockUpdate) await assertFolderMutable(id) + if (parentId !== undefined) await assertFolderMutable(parentId) + } + + const result = await updateFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + name, + locked, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to update folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error updating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/folders/[id] — Archive a folder and cascade to its contents. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived rows are included on purpose: `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be + * retried onto the same snapshot. 404ing here would strand those. + */ + const existing = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(id) + } + + const result = await deleteFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + folderName: existing.name, + }) + + if (!result.success) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + + return v2Data({ id, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error deleting folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts new file mode 100644 index 00000000000..85399484b5b --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -0,0 +1,278 @@ +/** + * @vitest-environment node + * + * Public v2 folders list/create: gate ordering, the required-`resourceType` + * departure from the internal default, and the lock check on create. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListFoldersForWorkspace, + mockCreateFolder, + mockAssertFolderMutable, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListFoldersForWorkspace: vi.fn(), + mockCreateFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + listFoldersForWorkspace: mockListFoldersForWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + createFolder: mockCreateFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/folders/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const FOLDER_API = { + id: 'fld_abc123', + resourceType: 'workflow' as const, + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, +} + +function buildRow(overrides: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Onboarding', +} + +describe('GET /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListFoldersForWorkspace.mockResolvedValue([FOLDER_API]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + + expect(res.status).toBe(404) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('resourceType=workflow') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted instead of defaulting to workflow', async () => { + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s on a resourceType outside the served set', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=file') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + expect(res.status).toBe(403) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public folder shape without internal scoping columns', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }, + ]) + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + }) + + it('passes the archived scope through', async () => { + await callList('workspaceId=workspace-1&resourceType=table&scope=archived') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + }) +}) + +describe('POST /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockCreateFolder.mockResolvedValue({ success: true, folder: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when the name is empty', async () => { + const res = await callCreate({ ...VALID_BODY, name: ' ' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Onboarding' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s when a sibling folder already has the name', async () => { + mockCreateFolder.mockResolvedValue({ + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the folder and returns 201', async () => { + const res = await callCreate({ ...VALID_BODY, parentId: null }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.folder).toMatchObject({ id: 'fld_abc123', name: 'Onboarding' }) + expect(body.data.folder.userId).toBeUndefined() + expect(mockCreateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts new file mode 100644 index 00000000000..2ce758499b4 --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFolderContract, v2ListFoldersContract } from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { createFolder } from '@/lib/folders/lifecycle' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, toV2FolderFromApi, v2FolderMutationError } from '@/app/api/v2/folders/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/folders — List a workspace's folder tree for one resource type. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFoldersContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, scope } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + + // One workspace's tree for one resource type is bounded → a single full page. + return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/folders — Create a folder in one of a workspace's resource trees. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFolderContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, name, parentId, sortOrder } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + // Locking is a workflow-only feature; other trees leave `locked` false. + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(parentId ?? null) + } + + const result = await createFolder({ + resourceType, + userId, + workspaceId, + name, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error creating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/utils.ts b/apps/sim/app/api/v2/folders/utils.ts new file mode 100644 index 00000000000..c041e880013 --- /dev/null +++ b/apps/sim/app/api/v2/folders/utils.ts @@ -0,0 +1,60 @@ +import type { folder as folderTable } from '@sim/db/schema' +import { omit } from '@sim/utils/object' +import type { NextResponse } from 'next/server' +import type { FolderApi } from '@/lib/api/contracts/folders' +import type { V2Folder } from '@/lib/api/contracts/v2/folders' +import type { FolderMutationErrorCode } from '@/lib/folders/status' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Shared serialization + error mapping for the v2 folders surface. */ + +type FolderRow = typeof folderTable.$inferSelect + +/** + * Narrows an already-serialized {@link FolderApi} (what the shared list query + * returns) to the public projection. + */ +export function toV2FolderFromApi(row: FolderApi): V2Folder { + return omit(row, ['userId', 'workspaceId']) +} + +/** + * Public folder projection. `userId` and `workspaceId` are internal scoping + * columns and are not exposed. + */ +export function toV2Folder(row: FolderRow): V2Folder { + return { + id: row.id, + resourceType: row.resourceType, + name: row.name, + parentId: row.parentId, + locked: row.locked, + sortOrder: row.sortOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + } +} + +/** + * Renders a folder mutation failure in the v2 error envelope. `locked` keeps its + * 423, matching what the table domain returns when the same mutation lock blocks + * a single-table delete. + */ +export function v2FolderMutationError( + errorCode: FolderMutationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Folder not found') + case 'conflict': + return v2Error('CONFLICT', message) + case 'locked': + return v2Error('LOCKED', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts new file mode 100644 index 00000000000..7acbeefa40d --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + * + * Public v2 MCP server detail: gate ordering, contract validation, workspace + * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceMcpServer, + mockPerformUpdateMcpServer, + mockPerformDeleteMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockPerformUpdateMcpServer: vi.fn(), + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performUpdateMcpServer: mockPerformUpdateMcpServer, + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: 'encrypted-secret', + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'disconnected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) }) + +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}` + +function callGet(query?: string) { + return GET(new NextRequest(url(query)), routeContext()) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +function callDelete(query?: string) { + return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) +} + +describe('GET /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the server does not exist in the workspace', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public server shape without header values', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: true, + }) + expect(JSON.stringify(body)).not.toContain('super-secret-token') + expect(JSON.stringify(body)).not.toContain('encrypted-secret') + expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + serverId: 'mcp-abc12345', + }) + }) +}) + +describe('PATCH /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body has an unknown field', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', bogus: true }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformUpdateMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('updates the server and returns the public shape', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer.id).toBe('mcp-abc12345') + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + name: 'Renamed', + enabled: false, + }) + ) + }) +}) + +describe('DELETE /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformDeleteMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callDelete() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('deletes the server and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } }) + expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts new file mode 100644 index 00000000000..dde71139693 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -0,0 +1,163 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { getWorkspaceMcpServer } from '@/lib/mcp/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServerDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const server = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!server) return v2Error('NOT_FOUND', 'MCP server not found') + + return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateMcpServer({ + workspaceId, + userId, + serverId: id, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.server) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server') + } + + return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request }) + if (!result.success) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts new file mode 100644 index 00000000000..1704df7b771 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -0,0 +1,316 @@ +/** + * @vitest-environment node + * + * Public v2 MCP servers list/create: gate ordering, contract validation, the + * write-only `headers` projection, and the 409-on-duplicate-URL departure from + * the internal upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceMcpServers, + mockGetWorkspaceMcpServer, + mockMcpServerIdExists, + mockPerformCreateMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceMcpServers: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockMcpServerIdExists: vi.fn(), + mockPerformCreateMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + listWorkspaceMcpServers: mockListWorkspaceMcpServers, + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, + mcpServerIdExists: mockMcpServerIdExists, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: mockPerformCreateMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/mcp-servers/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: new Date('2024-01-02T00:00:00Z'), + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 4, + lastToolsRefresh: new Date('2024-01-02T00:00:00Z'), + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/mcp-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', +} + +describe('GET /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceMcpServers.mockResolvedValue([buildRow()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public server shape in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'mcp-abc12345', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + authType: 'headers', + url: 'https://mcp.example.com/sse', + timeout: 30000, + retries: 3, + enabled: true, + connectionStatus: 'connected', + lastError: null, + toolCount: 4, + lastToolsRefresh: '2024-01-02T00:00:00.000Z', + lastConnected: '2024-01-02T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: false, + }, + ]) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) + + it('never returns configured header values', async () => { + const res = await callList('workspaceId=workspace-1') + const raw = JSON.stringify(await res.json()) + + expect(raw).not.toContain('super-secret-token') + expect(raw).not.toContain('"headers":') + }) +}) + +describe('POST /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockMcpServerIdExists.mockResolvedValue(false) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: false, + }) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body is missing a required field', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('{{ENV_VAR}}') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url is not an absolute http(s) URL', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' }) + expect(res.status).toBe(400) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate URL without letting the lib upsert', async () => { + mockMcpServerIdExists.mockResolvedValue(true) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create made the lib upsert instead of insert', async () => { + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the server and returns 201 with the public shape', async () => { + const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + name: 'Docs server', + hasHeaders: true, + headerNames: ['Authorization'], + }) + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformCreateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer tok' }, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts new file mode 100644 index 00000000000..96dc3531d55 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -0,0 +1,156 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateMcpServerContract, + v2ListMcpServersContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateMcpServer } from '@/lib/mcp/orchestration' +import { + getWorkspaceMcpServer, + listWorkspaceMcpServers, + mcpServerIdExists, +} from '@/lib/mcp/queries' +import { generateMcpServerId } from '@/lib/mcp/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListMcpServersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceMcpServers({ workspaceId }) + + // The per-workspace server set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/mcp-servers — Register a new MCP server. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateMcpServerContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * The server id is a deterministic hash of workspace + normalized URL, and + * `performCreateMcpServer` upserts onto it — a second registration of the + * same URL silently overwrites the first. The internal surface and the + * copilot rely on that; a public create must not, so the collision is + * detected here, before the lib is given a chance to clobber the row. + */ + const serverId = generateMcpServerId(workspaceId, body.url) + if (await mcpServerIdExists({ workspaceId, serverId })) { + return v2Error( + 'CONFLICT', + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + ) + } + + const result = await performCreateMcpServer({ + workspaceId, + userId, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.serverId) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') + } + + // A concurrent create won the id race and the lib upserted onto it. + if (result.updated) { + return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') + } + + const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts new file mode 100644 index 00000000000..ba4fec6ee87 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -0,0 +1,50 @@ +import type { NextResponse } from 'next/server' +import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers' +import type { McpServerRow } from '@/lib/mcp/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 MCP server surface. + */ + +/** + * Projects a stored MCP server row onto the public shape. + * + * The row is parsed through {@link v2McpServerSchema}, whose strip behaviour is + * the security boundary: `headers`, `oauthClientSecret`, `statusConfig`, and the + * rest of the row are dropped rather than enumerated by hand, so a column added + * later cannot leak by omission. Header *names* are lifted out explicitly. + */ +export function toV2McpServer(row: McpServerRow): V2McpServer { + const headers = (row.headers ?? {}) as Record + const headerNames = Object.keys(headers) + return v2McpServerSchema.parse({ + ...row, + hasHeaders: headerNames.length > 0, + headerNames, + hasOauthClientSecret: Boolean(row.oauthClientSecret), + }) +} + +/** + * Renders an MCP orchestration failure in the v2 error envelope. + * + * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403. + * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's + * input is at fault, so it surfaces as a 400 rather than implying a Sim outage. + */ +export function v2McpOrchestrationError( + errorCode: string | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'not_found': + return v2Error('NOT_FOUND', 'MCP server not found') + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'bad_gateway': + return v2Error('BAD_REQUEST', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts new file mode 100644 index 00000000000..88a2a33ac72 --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -0,0 +1,311 @@ +/** + * @vitest-environment node + * + * Public v2 skill detail: the get-by-id that has no internal equivalent, plus + * the per-id update/delete that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetSkillById, + mockPerformUpdateSkill, + mockPerformDeleteSkill, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetSkillById: vi.fn(), + mockPerformUpdateSkill: vi.fn(), + mockPerformDeleteSkill: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: mockGetSkillById, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performUpdateSkill: mockPerformUpdateSkill, + performDeleteSkill: mockPerformDeleteSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/skills/skl_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetSkillById.mockResolvedValue(buildSkill()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the skill is not in the workspace', async () => { + mockGetSkillById.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the single skill including its body', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockGetSkillById).toHaveBeenCalledWith({ + skillId: 'skl_abc123', + workspaceId: 'workspace-1', + }) + }) +}) + +describe('PATCH /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateSkill.mockResolvedValue({ + success: true, + skill: buildSkill({ description: 'Updated' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('403s when the caller is not a skill editor', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Skill editor access required to modify "refund-policy"', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('400s when the orchestration rejects a built-in skill', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Built-in') + }) + + it('updates the skill and returns the single skill', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.skill.description).toBe('Updated') + expect(Array.isArray(body.data)).toBe(false) + expect(mockPerformUpdateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + description: 'Updated', + source: 'api', + }) + ) + }) +}) + +describe('DELETE /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the skill is a read-only built-in', async () => { + mockPerformDeleteSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + }) + + it('deletes the skill and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } }) + expect(mockPerformDeleteSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts new file mode 100644 index 00000000000..9fa957964dd --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -0,0 +1,161 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteSkillContract, + v2GetSkillContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' +import { getSkillById } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skill = await getSkillById({ skillId: id, workspaceId }) + if (!skill) return v2Error('NOT_FOUND', 'Skill not found') + + return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, content } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: id, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/skills/[id] — Delete a skill. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteSkill({ + workspaceId, + userId, + skillId: id, + source: 'api', + request, + }) + + if (!result.success) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts new file mode 100644 index 00000000000..6cf0ae6f52f --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -0,0 +1,261 @@ +/** + * @vitest-environment node + * + * Public v2 skills list/create: gate ordering, contract validation, and the + * single-resource create that replaced the internal bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListSkills: vi.fn(), + mockPerformCreateSkill: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + listSkills: mockListSkills, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performCreateSkill: mockPerformCreateSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/skills/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', +} + +describe('GET /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListSkills.mockResolvedValue([buildSkill()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns summaries without skill bodies in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) +}) + +describe('POST /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the body is missing content', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the name is not kebab-case', async () => { + const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' }) + expect(res.status).toBe(400) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the orchestration rejects a built-in skill name', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "deploy-workflow" is reserved by a built-in skill', + errorCode: 'validation', + }) + + const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toContain('built-in') + }) + + it('409s when the skill name is already taken', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "refund-policy" is unavailable in this workspace', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the skill and returns 201 with the single skill, not the workspace list', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformCreateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts new file mode 100644 index 00000000000..5e1d6a825b2 --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateSkill } from '@/lib/skills/orchestration' +import { listSkills } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/skills — List skills in a workspace, built-ins included. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListSkillsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skills = await listSkills({ workspaceId }) + + // The per-workspace skill set is small and bounded → a single full page. + return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing skills`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/skills — Create a skill. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateSkillContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, content } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateSkill({ + workspaceId, + userId, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts new file mode 100644 index 00000000000..a1cc30ceb02 --- /dev/null +++ b/apps/sim/app/api/v2/skills/utils.ts @@ -0,0 +1,48 @@ +import type { skill } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills' +import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 skills surface. + */ + +type SkillRow = typeof skill.$inferSelect + +/** List projection — no `content`; skill bodies are fetched per skill. */ +export function toV2SkillSummary(row: SkillRow): V2SkillSummary { + return { + id: row.id, + name: row.name, + description: row.description, + readOnly: isBuiltinSkillId(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Detail projection — the summary plus the skill body. */ +export function toV2Skill(row: SkillRow): V2Skill { + return { ...toV2SkillSummary(row), content: row.content } +} + +/** Renders a skill orchestration failure in the v2 error envelope. */ +export function v2SkillOrchestrationError( + errorCode: SkillOrchestrationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Skill not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 31af51cd489..3b29193d87b 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -36,13 +36,13 @@ export const skillEditorSchema = z.object({ export type SkillEditor = z.output -const skillNameSchema = z +export const skillNameSchema = z .string() .min(1, 'Skill name is required') .max(64) .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') -const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) -const skillContentSchema = z +export const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +export const skillContentSchema = z .string() .min(1, 'Content is required') .max(50_000, 'Content is too large') diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts new file mode 100644 index 00000000000..205dd794bb7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + normalizeCredentialEnvKey, + workspaceCredentialRoleSchema, + workspaceCredentialTypeSchema, +} from '@/lib/api/contracts/credentials' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' + +/** + * v2 credential contracts. + * + * Secret material — service-account JSON, API tokens, signing secrets, bot + * tokens, client secrets — is accepted on write and **never** returned on read, + * the same treatment MCP request headers get. A read exposes only whether a + * secret is stored (`hasServiceAccountKey`). + * + * `oauth` credentials cannot be created here: they are minted by the interactive + * OAuth connect flow and are bound to an `account` row the caller authorized in + * a browser. They are listed, read, updated, and deleted like any other type. + * + * Credential sharing (`/api/credentials/[id]/members`) is not part of this + * surface. + */ + +const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ + +/** The types a public caller can create. `oauth` requires the browser connect flow. */ +export const v2CreatableCredentialTypeSchema = z.enum( + ['env_workspace', 'env_personal', 'service_account'], + { error: 'type must be one of env_workspace, env_personal, service_account' } +) +export type V2CreatableCredentialType = z.output + +/** + * Public credential projection. `workspaceId` (supplied by the caller), + * `createdBy`, and every encrypted column are omitted. + */ +export const v2CredentialSchema = z.object({ + id: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + description: z.string().nullable(), + /** The integration this credential authenticates against, when it has one. */ + providerId: z.string().nullable(), + /** The linked OAuth account, for `oauth` credentials. */ + accountId: z.string().nullable(), + /** The environment-variable name, for `env_workspace` / `env_personal` credentials. */ + envKey: z.string().nullable(), + /** Whether a service-account secret is stored. The secret itself is never returned. */ + hasServiceAccountKey: z.boolean(), + /** The caller's role on this credential. */ + role: workspaceCredentialRoleSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Credential = z.output + +/** `{ credential }` payload for single-credential reads and mutations. */ +export const v2CredentialDataSchema = z.object({ credential: v2CredentialSchema }) +export type V2CredentialData = z.output + +export const v2CredentialDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CredentialDeleteData = z.output + +export const v2CredentialParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CredentialParams = z.output + +export const v2CredentialWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CredentialWorkspaceQuery = z.output + +export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ + type: workspaceCredentialTypeSchema.optional(), + providerId: z.string().min(1, 'providerId cannot be empty').optional(), +}) +export type V2ListCredentialsQuery = z.output + +/** Write-only secret fields, shared by create and the reconnect-style update. */ +const credentialSecretFields = { + /** Write-only. Google-style service-account JSON key. */ + serviceAccountJson: z.string().min(1, 'serviceAccountJson cannot be empty').optional(), + /** Write-only. Slack custom-bot signing secret. */ + signingSecret: z.string().trim().min(1, 'signingSecret cannot be empty').optional(), + /** Write-only. Slack custom-bot token. */ + botToken: z.string().trim().min(1, 'botToken cannot be empty').optional(), + /** Write-only. Atlassian API token. */ + apiToken: z.string().trim().min(1, 'apiToken cannot be empty').optional(), + domain: z.string().trim().min(1, 'domain cannot be empty').optional(), + /** Write-only. Client-credentials service-account id/secret pair. */ + clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), + clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), + orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), +} as const + +export const v2CreateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + type: v2CreatableCredentialTypeSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).optional(), + providerId: z.string().trim().min(1, 'providerId cannot be empty').optional(), + /** Required for `env_workspace` / `env_personal`. Accepts `NAME` or `{{NAME}}`. */ + envKey: z.string().trim().min(1, 'envKey cannot be empty').optional(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + if (data.type === 'service_account') { + for (const field of getServiceAccountRequiredFields(data.providerId)) { + if (!data[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${data.providerId ?? 'service account'} credentials`, + }) + } + } + return + } + + const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : '' + if (!normalizedEnvKey) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey is required for env credentials', + }) + return + } + if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey must contain only letters, numbers, and underscores', + }) + } + }) +export type V2CreateCredentialBody = z.input + +/** + * Update body. Renaming and re-describing apply to any type; the secret fields + * rotate a stored secret in place (the provider re-verifies it). + */ +export const v2UpdateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).nullish(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + const { workspaceId: _workspaceId, ...changes } = data + if (Object.values(changes).every((value) => value === undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['displayName'], + message: 'At least one field to change is required', + }) + } + }) +export type V2UpdateCredentialBody = z.input + +/** + * Credential list. A workspace's credential set is small and bounded, so the + * full visible set is returned as a single page (`nextCursor` is always `null`); + * the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCredentialsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials', + query: v2ListCredentialsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialSchema), + }, +}) + +export const v2CreateCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + body: v2CreateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2GetCredentialContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2UpdateCredentialContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + body: v2UpdateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts new file mode 100644 index 00000000000..7082c6d1c82 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -0,0 +1,148 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 custom tool contracts. + * + * The internal `/api/tools/custom` surface is a bulk upsert with no per-id + * update, and it tolerates legacy *personal* tools (`workspaceId: null`, owned + * by one user) alongside workspace ones. v2 splits create from update and is + * workspace-scoped in every direction — a workspace key never reaches another + * user's personal tool. + * + * The JSON-Schema `schema` field is reused verbatim from the internal contract: + * it is an OpenAI-style function declaration whose `parameters.properties` are + * caller-defined, so the shape is deliberately open below the function level. + */ + +const customToolTitleSchema = z + .string({ error: 'title is required' }) + .min(1, 'title is required') + .max(200, 'title must be at most 200 characters') + +const customToolCodeSchema = z + .string({ error: 'code is required' }) + .max(100_000, 'code must be at most 100000 characters') + +export const v2CustomToolSchema = z.object({ + id: z.string(), + title: z.string(), + /** OpenAI-style function declaration describing the tool's callable surface. */ + schema: customToolSchemaSchema, + /** The tool's implementation body, executed in Sim's sandboxed function runtime. */ + code: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2CustomTool = z.output + +/** `{ customTool }` payload for single-tool reads and mutations. */ +export const v2CustomToolDataSchema = z.object({ customTool: v2CustomToolSchema }) +export type V2CustomToolData = z.output + +export const v2CustomToolDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CustomToolDeleteData = z.output + +export const v2CustomToolParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CustomToolParams = z.output + +export const v2CustomToolWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CustomToolWorkspaceQuery = z.output + +export const v2CreateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema, + schema: customToolSchemaSchema, + code: customToolCodeSchema, + }) + .strict() +export type V2CreateCustomToolBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema.optional(), + schema: customToolSchemaSchema.optional(), + code: customToolCodeSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.title === undefined && body.schema === undefined && body.code === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['title'], + message: 'At least one of title, schema, or code is required', + }) + } + }) +export type V2UpdateCustomToolBody = z.input + +/** + * Custom tool list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCustomToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools', + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CustomToolSchema), + }, +}) + +export const v2CreateCustomToolContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/custom-tools', + body: v2CreateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2GetCustomToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2UpdateCustomToolContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + body: v2UpdateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2DeleteCustomToolContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts new file mode 100644 index 00000000000..6f12fe25637 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { + folderCascadeCountsSchema, + folderResourceTypeSchema, + folderScopeSchema, + servedFolderResourceTypeSchema, +} from '@/lib/api/contracts/folders' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 folder contracts. + * + * One folder engine serves several resource trees (`workflow`, `knowledge_base`, + * `table`), discriminated by `resourceType`. The internal surface defaults that + * field to `workflow` so an old client that never sends it keeps working across + * a deploy; the public surface has no such legacy, and defaulting it would let a + * caller silently file a knowledge-base folder into the workflow tree where the + * Knowledge page can never see it again. So v2 **requires** it on every + * operation, reusing the served enum with its default stripped. + * + * `duplicate`, `restore`, and `reorder` are not part of the public surface. + */ + +/** The served resource types, required rather than defaulted. */ +export const v2FolderResourceTypeSchema = servedFolderResourceTypeSchema.unwrap() +export type V2FolderResourceType = z.output + +/** + * Public folder projection. `userId` (the creator) and `workspaceId` (already + * known to the caller, who supplied it) are internal columns and not exposed. + */ +export const v2FolderSchema = z.object({ + id: z.string(), + resourceType: folderResourceTypeSchema, + name: z.string(), + parentId: z.string().nullable(), + /** Workflow folders only; always `false` for the other resource types. */ + locked: z.boolean(), + sortOrder: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + /** Set when the folder is archived (in Recently Deleted) rather than live. */ + deletedAt: z.string().nullable(), +}) +export type V2Folder = z.output + +/** `{ folder }` payload for single-folder reads and mutations. */ +export const v2FolderDataSchema = z.object({ folder: v2FolderSchema }) +export type V2FolderData = z.output + +/** + * Delete acknowledgement. `deletedItems` reports what the cascade archived + * alongside the folder; only the key matching `resourceType` is populated. + */ +export const v2FolderDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + deletedItems: folderCascadeCountsSchema.optional(), +}) +export type V2FolderDeleteData = z.output + +export const v2FolderParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2FolderParams = z.output + +/** Query for the id-keyed reads and the delete. */ +export const v2FolderScopedQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, +}) +export type V2FolderScopedQuery = z.output + +export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ + /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ + scope: folderScopeSchema.default('active'), +}) +export type V2ListFoldersQuery = z.output + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + /** Explicit `null` creates the folder at the workspace root. */ + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() +export type V2CreateFolderBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + /** Workflow folders only, and changing it requires workspace `admin`. */ + locked: z.boolean().optional(), + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.locked === undefined && + body.parentId === undefined && + body.sortOrder === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, locked, parentId, or sortOrder is required', + }) + } + }) +export type V2UpdateFolderBody = z.input + +/** + * Folder list. A workspace's folder tree for one resource type is small and + * bounded, so the full set is returned as a single page (`nextCursor` is always + * `null`); the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders', + query: v2ListFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FolderSchema), + }, +}) + +export const v2CreateFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/folders', + body: v2CreateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2GetFolderContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2UpdateFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + body: v2UpdateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2DeleteFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts new file mode 100644 index 00000000000..54324148896 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -0,0 +1,217 @@ +import { z } from 'zod' +import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +/** + * v2 MCP server contracts. + * + * The routes are thin wrappers over `lib/mcp/orchestration`, but the public + * contract deliberately departs from the internal `/api/mcp/servers` shape in + * four places, each closing a hole that is merely awkward in a browser session + * and unsafe over an API key: + * + * 1. `headers` is write-only. The internal list returns the header map verbatim, + * which is where callers put `Authorization: Bearer …`; reusing that shape + * here would turn a read-scoped key into a token-exfiltration primitive. The + * public read exposes `hasHeaders` and `headerNames` only. + * 2. `url` must be a real absolute `http(s)` URL — the internal body accepts any + * string (or none at all). + * 3. `url` may not carry a `{{ENV_VAR}}` template. `lib/mcp/domain-check` skips + * both the domain allowlist and the SSRF resolve for templated hostnames, + * deferring validation to call time; over an API key that is a stored SSRF + * path. + * 4. Bodies are strict. An unrecognized field is a caller mistake, not something + * to silently pass through to storage. + */ + +/** A `{{ENV_VAR}}` reference anywhere in a URL defers domain/SSRF validation to call time. */ +function hasEnvVarTemplate(value: string): boolean { + return createEnvVarPattern().test(value) +} + +const v2McpServerUrlSchema = z + .string({ error: 'url is required' }) + .min(1, 'url is required') + .max(2048, 'url must be at most 2048 characters') + .refine((value) => !hasEnvVarTemplate(value), { + error: 'url must not contain {{ENV_VAR}} references on the public API', + }) + .refine( + (value) => { + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } + }, + { error: 'url must be an absolute http or https URL' } + ) + +const v2McpServerHeadersSchema = z.record( + z.string().min(1, 'Header names cannot be empty'), + z.string() +) + +/** + * Public MCP server projection. + * + * The field schemas are picked from {@link mcpServerSchema} so the legacy-row + * tolerance (`.catch()` on the free-text `transport`/`authType`/ + * `connection_status` columns) is shared with the internal surface. The pick is + * re-wrapped in a plain object so the result strips unknown keys instead of + * passing them through — that strip is what keeps `headers` and + * `oauthClientSecret` out of the response when a whole row is handed to it. + */ +export const v2McpServerSchema = z.object({ + ...mcpServerSchema.pick({ + id: true, + name: true, + description: true, + transport: true, + authType: true, + url: true, + timeout: true, + retries: true, + enabled: true, + connectionStatus: true, + lastError: true, + toolCount: true, + lastToolsRefresh: true, + lastConnected: true, + createdAt: true, + updatedAt: true, + oauthClientId: true, + }).shape, + /** Whether any request headers are configured. Values are never returned. */ + hasHeaders: z.boolean(), + /** Names of the configured request headers. Values are never returned. */ + headerNames: z.array(z.string()), + hasOauthClientSecret: z.boolean(), +}) +export type V2McpServer = z.output + +/** `{ mcpServer }` payload for single-server reads and mutations. */ +export const v2McpServerDataSchema = z.object({ mcpServer: v2McpServerSchema }) +export type V2McpServerData = z.output + +/** Delete acknowledgement — the id of the server that was deleted. */ +export const v2McpServerDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2McpServerDeleteData = z.output + +export const v2McpServerParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2McpServerParams = z.output + +export const v2McpServerWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2McpServerWorkspaceQuery = z.output + +export const v2CreateMcpServerBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z + .string({ error: 'name is required' }) + .min(1, 'name is required') + .max(255, 'name must be at most 255 characters'), + description: z.string().max(2000, 'description must be at most 2000 characters').optional(), + transport: mcpTransportSchema.optional(), + url: v2McpServerUrlSchema, + authType: mcpAuthTypeSchema.optional(), + /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ + headers: v2McpServerHeadersSchema.optional(), + timeout: z + .number() + .int('timeout must be an integer number of milliseconds') + .min(1000, 'timeout must be at least 1000ms') + .max(300000, 'timeout must be at most 300000ms') + .optional(), + retries: z + .number() + .int('retries must be an integer') + .min(0, 'retries cannot be negative') + .max(10, 'retries must be at most 10') + .optional(), + enabled: z.boolean().optional(), + oauthClientId: z.string().max(512, 'oauthClientId is too long').nullable().optional(), + /** Write-only. Reads expose `hasOauthClientSecret` instead. */ + oauthClientSecret: z.string().max(2048, 'oauthClientSecret is too long').nullable().optional(), + }) + .strict() +export type V2CreateMcpServerBody = z.input + +/** + * Update body. Every configuration field is optional; `workspaceId` stays + * required so the request is tenant-scoped before the server id is resolved. + */ +export const v2UpdateMcpServerBodySchema = v2CreateMcpServerBodySchema + .partial() + .extend({ workspaceId: workspaceIdSchema }) + .strict() +export type V2UpdateMcpServerBody = z.input + +/** + * MCP server list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListMcpServersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers', + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2McpServerSchema), + }, +}) + +export const v2CreateMcpServerContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/mcp-servers', + body: v2CreateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2GetMcpServerContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2UpdateMcpServerContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + body: v2UpdateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2DeleteMcpServerContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts new file mode 100644 index 00000000000..3bc7174ea81 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -0,0 +1,156 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 skills contracts. + * + * Two departures from the internal `/api/skills` shape: + * + * 1. **Single-resource writes.** The internal `POST` takes an array, conflates + * create and update, and answers with the whole workspace skill list. v2 + * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[id]`, each + * answering with the one skill that changed. + * 2. **`content` is detail-only.** A skill body is up to 50 000 characters, so + * the list returns summaries and the full body is fetched per skill from + * `GET /v2/skills/[id]`. + * + * Field validation lives in `lib/skills/orchestration`, so these schemas and the + * lib enforce the same limits — the schemas reuse the shared field primitives + * rather than restating them. + */ + +/** List item — everything but the skill body. */ +export const v2SkillSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + /** True for built-in template skills, which ship with Sim and cannot be written to. */ + readOnly: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2SkillSummary = z.output + +/** Detail — the summary plus the skill body. */ +export const v2SkillSchema = v2SkillSummarySchema.extend({ + content: z.string(), +}) +export type V2Skill = z.output + +/** `{ skill }` payload for single-skill reads and mutations. */ +export const v2SkillDataSchema = z.object({ skill: v2SkillSchema }) +export type V2SkillData = z.output + +export const v2SkillDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2SkillDeleteData = z.output + +export const v2SkillParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2SkillParams = z.output + +export const v2SkillWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2SkillWorkspaceQuery = z.output + +export const v2CreateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema, + description: skillDescriptionSchema, + content: skillContentSchema, + }) + .strict() +export type V2CreateSkillBody = z.input + +/** + * Update body. Omitted fields keep their stored values, so a partial edit can + * never clobber a concurrent change to a field the caller did not send. + */ +export const v2UpdateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.content === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or content is required', + }) + } + }) +export type V2UpdateSkillBody = z.input + +/** + * Skill list. The per-workspace set is small and bounded, so the full set is + * returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListSkillsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills', + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SkillSummarySchema), + }, +}) + +export const v2CreateSkillContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/skills', + body: v2CreateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2GetSkillContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2UpdateSkillContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + body: v2UpdateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2DeleteSkillContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index e3c1c7bfebc..61ab053f07b 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { captureServerEvent } from '@/lib/posthog/server' -import { getSkillActorContext } from '@/lib/skills/access' -import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, +} from '@/lib/skills/orchestration' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -77,35 +78,16 @@ export async function executeManageSkill( } } - const { skills: resultSkills } = await upsertSkills({ - skills: [{ name: params.name, description: params.description, content: params.content }], + const result = await performCreateSkill({ workspaceId, userId: context.userId, + name: params.name, + description: params.description, + content: params.content, + source: 'tool_input', }) - const created = resultSkills.find((s) => s.name === params.name) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: created?.id, - resourceName: params.name, - description: `Created skill "${params.name}"`, - metadata: { source: 'tool_input' }, - }) - if (created?.id) { - captureServerEvent( - context.userId, - 'skill_created', - { - skill_id: created.id, - skill_name: params.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to create skill' } } return { @@ -113,9 +95,9 @@ export async function executeManageSkill( output: { success: true, operation, - skillId: created?.id, - name: params.name, - message: `Created skill "${params.name}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Created skill "${result.skill.name}"`, }, } } @@ -131,66 +113,28 @@ export async function executeManageSkill( } } - if (isBuiltinSkillId(params.skillId)) { - return { success: false, error: 'Built-in skills are read-only and cannot be modified' } - } - - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - // Partial update: omitted fields keep their current values server-side. - await upsertSkills({ - skills: [ - { - id: params.skillId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - ...(params.content ? { content: params.content } : {}), - }, - ], + const result = await performUpdateSkill({ workspaceId, userId: context.userId, + skillId: params.skillId, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), + source: 'tool_input', }) - - const updatedName = params.name || actor.skill.name - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_UPDATED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - resourceName: updatedName, - description: `Updated skill "${updatedName}"`, - metadata: { source: 'tool_input' }, - }) - captureServerEvent( - context.userId, - 'skill_updated', - { - skill_id: params.skillId, - skill_name: updatedName, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to update skill' } + } return { success: true, output: { success: true, operation, - skillId: params.skillId, - name: updatedName, - message: `Updated skill "${updatedName}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Updated skill "${result.skill.name}"`, }, } } @@ -200,39 +144,15 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } - if (!isBuiltinSkillId(params.skillId)) { - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - } - - const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) - if (!deleted) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - description: 'Deleted skill', - metadata: { source: 'tool_input' }, + userId: context.userId, + skillId: params.skillId, + source: 'tool_input', }) - captureServerEvent( - context.userId, - 'skill_deleted', - { skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to delete skill' } + } return { success: true, diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts new file mode 100644 index 00000000000..57ec7c42e92 --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -0,0 +1,571 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { account, credential, credentialMember } from '@sim/db/schema' +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 { getCredentialActorContext } from '@/lib/credentials/access' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import { + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { getServiceConfigByProviderId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialCreateOrchestration') + +/** + * Credential creation, shared by the session surface (`POST /api/credentials`) + * and the public API (`POST /api/v2/credentials`). + * + * Everything here was previously inline in the session route: the per-type + * source resolution, the existing-credential replay rules, the organization + * mutation locks and in-transaction re-authorization, and the audit. Callers + * render the outcome in their own envelope from `errorCode` / + * `providerErrorCode`. + */ + +type CredentialRow = typeof credential.$inferSelect +type CredentialType = CredentialRow['type'] +type DbOrTx = typeof db | Parameters[0]>[0] + +/** + * Raised by the in-transaction duplicate guard when a concurrent request slipped + * a row in between the outer existence check and the INSERT. + */ +class DuplicateCredentialError extends Error { + constructor() { + super('duplicate_display_name') + this.name = 'DuplicateCredentialError' + } +} + +export interface PerformCreateCredentialParams { + workspaceId: string + type: CredentialType + userId: string + actorName?: string | null + actorEmail?: string | null + displayName?: string + description?: string + providerId?: string + accountId?: string + envKey?: string + envOwnerUserId?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + orgId?: string + /** + * Client-supplied credential id, honored only for `slack-custom-bot`: the + * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before + * secrets exist, so the id must be known up front. + */ + id?: string + request?: NextRequest +} + +export interface PerformCreateCredentialResult { + success: boolean + error?: string + errorCode?: CredentialOrchestrationErrorCode + /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ + providerErrorCode?: string + /** A provider outage rather than a rejected secret — callers surface 502, not 400. */ + providerUnavailable?: boolean + credential?: CredentialRow + /** False when an existing credential matched the source and was returned instead. */ + created?: boolean +} + +interface ExistingCredentialSourceParams { + workspaceId: string + type: CredentialType + accountId?: string | null + envKey?: string | null + envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null +} + +/** + * Finds the credential that already occupies a source slot. Each type keys on a + * different tuple, matching the partial unique indexes on the table. + */ +async function findExistingCredentialBySourceWith( + exec: DbOrTx, + params: ExistingCredentialSourceParams +): Promise { + const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params + + if (type === 'oauth' && accountId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'oauth'), + eq(credential.accountId, accountId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_workspace' && envKey) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, envKey) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_personal' && envKey && envOwnerUserId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envKey, envKey), + eq(credential.envOwnerUserId, envOwnerUserId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'service_account' && displayName && providerId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, providerId), + eq(credential.displayName, displayName) + ) + ) + .limit(1) + return row ?? null + } + + return null +} + +function failure( + error: string, + errorCode: CredentialOrchestrationErrorCode, + extra: Partial = {} +): PerformCreateCredentialResult { + return { success: false, error, errorCode, ...extra } +} + +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const { workspaceId, type, userId } = params + + try { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.canWrite) { + return failure('Write permission required', 'forbidden') + } + + let resolvedDisplayName = params.displayName?.trim() ?? '' + const resolvedDescription = params.description?.trim() || null + let resolvedProviderId: string | null = params.providerId ?? null + let resolvedAccountId: string | null = params.accountId ?? null + const resolvedEnvKey: string | null = params.envKey + ? normalizeCredentialEnvKey(params.envKey) + : null + let resolvedEnvOwnerUserId: string | null = null + let resolvedEncryptedServiceAccountKey: string | null = null + const extraAuditMetadata: Record = {} + + if (type === 'oauth') { + const [accountRow] = await db + .select({ + id: account.id, + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where(eq(account.id, params.accountId!)) + .limit(1) + + if (!accountRow) return failure('OAuth account not found', 'not_found') + + if (accountRow.userId !== userId) { + return failure( + 'Only account owners can create oauth credentials for an account', + 'forbidden' + ) + } + + if (params.providerId !== accountRow.providerId) { + return failure('providerId does not match the selected OAuth account', 'validation') + } + if (!resolvedDisplayName) { + resolvedDisplayName = + getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId + } + } else if (type === 'service_account') { + try { + const secret = await verifyAndBuildServiceAccountSecret(params.providerId ?? '', { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + }) + resolvedProviderId = secret.providerId + resolvedAccountId = null + resolvedEnvOwnerUserId = null + if (!resolvedDisplayName) resolvedDisplayName = secret.displayName + resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + Object.assign(extraAuditMetadata, secret.auditMetadata) + } catch (error) { + if (error instanceof ServiceAccountSecretError) { + return failure(error.message, 'validation') + } + throw error + } + } else if (type === 'env_personal') { + resolvedEnvOwnerUserId = params.envOwnerUserId ?? userId + if (resolvedEnvOwnerUserId !== userId) { + return failure( + 'Only the current user can create personal env credentials for themselves', + 'forbidden' + ) + } + resolvedProviderId = null + resolvedAccountId = null + resolvedDisplayName = resolvedEnvKey || '' + } else { + resolvedProviderId = null + resolvedAccountId = null + resolvedEnvOwnerUserId = null + resolvedDisplayName = resolvedEnvKey || '' + } + + if (!resolvedDisplayName) return failure('Display name is required', 'validation') + + const existingCredential = await findExistingCredentialBySourceWith(db, { + workspaceId, + type, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + + if (existingCredential) { + /** + * A retried custom-bot create with the SAME pre-generated id is an + * idempotent replay and falls through to the normal existing-credential + * path. Any other name collision must fail loudly: returning the existing + * row as success would orphan the new id already embedded in the user's + * Slack Request URL (Slack would post to a URL no credential resolves). + */ + if ( + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && + params.id && + existingCredential.id !== params.id + ) { + return failure( + `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + /** + * Token service-account creates always carry a fresh token that must be + * stored — falling through to the existing-credential path would return + * the old credential as success and silently drop the submitted token. + */ + if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + const access = await getCredentialActorContext(existingCredential.id, userId, { + workspaceAccess, + }) + + if (!access.member && !access.isAdmin) { + return failure('A credential with this source already exists in this workspace', 'conflict') + } + + const shouldUpdateDisplayName = + type === 'oauth' && + resolvedDisplayName && + resolvedDisplayName !== existingCredential.displayName + const shouldUpdateDescription = + params.description !== undefined && + (existingCredential.description ?? null) !== resolvedDescription + + if (access.isAdmin && (shouldUpdateDisplayName || shouldUpdateDescription)) { + await db + .update(credential) + .set({ + ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), + ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), + updatedAt: new Date(), + }) + .where(eq(credential.id, existingCredential.id)) + + const [updatedCredential] = await db + .select() + .from(credential) + .where(eq(credential.id, existingCredential.id)) + .limit(1) + + return { + success: true, + credential: updatedCredential ?? existingCredential, + created: false, + } + } + + return { success: true, credential: existingCredential, created: false } + } + + const now = new Date() + const credentialId = + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && params.id ? params.id : generateId() + + const creationResult = await db.transaction(async (tx) => { + /** + * Discover the organization lock scope inside this transaction, then + * acquire the same organization → user → membership locks as org + * removal/transfer and re-authorize from the transaction before writing. + * + * If this insert wins, transfer sees the new source-owned personal + * credential and blocks. If transfer wins, its permission/member cleanup + * is visible to the authoritative re-read below and the insert is refused. + */ + const plannedContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + }) + if (!plannedContext) return failure('Write permission required', 'forbidden') + + await acquireOrganizationUserMutationLocks(tx, { + userId, + organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], + }) + + const currentContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + forUpdate: true, + }) + if (!currentContext) return failure('Write permission required', 'forbidden') + if (currentContext.organizationId !== plannedContext.organizationId) { + return failure( + 'Workspace organization changed while creating the credential. Please retry.', + 'conflict' + ) + } + if (!currentContext.canWrite) return failure('Write permission required', 'forbidden') + + /** + * `service_account` has no DB-level unique index on (workspaceId, + * providerId, displayName), so re-check inside the tx. OAuth/env_* are + * guarded by partial unique indexes and fall through to the 23505 handler. + */ + if (type === 'service_account') { + const innerExisting = await findExistingCredentialBySourceWith(tx, { + workspaceId, + type, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + if (innerExisting) throw new DuplicateCredentialError() + } + + await tx.insert(credential).values({ + id: credentialId, + workspaceId, + type, + displayName: resolvedDisplayName, + description: resolvedDescription, + providerId: resolvedProviderId, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + + if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { + for (const memberUserId of currentContext.memberUserIds) { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: memberUserId, + role: memberUserId === userId ? 'admin' : 'member', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + } else { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + + return { success: true as const } + }) + + if (!creationResult.success) return creationResult + + const [created] = await db + .select() + .from(credential) + .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: { + credentialType: type, + providerId: resolvedProviderId, + ...extraAuditMetadata, + }, + request: params.request, + }) + + return { success: true, credential: created, created: true } + } catch (error: unknown) { + if (error instanceof AtlassianValidationError) { + logger.warn(`Atlassian credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return failure(error.code, 'validation', { providerErrorCode: error.code }) + } + if (error instanceof TokenServiceAccountValidationError) { + logger.warn(`Token service-account credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + // A provider outage is an infra failure, not a bad request. + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: error.code === 'provider_unavailable', + }) + } + if (error instanceof DuplicateCredentialError) { + return failure('A credential with that name already exists in this workspace.', 'conflict', { + providerErrorCode: 'duplicate_display_name', + }) + } + + const pgCode = getPostgresErrorCode(error) + if (pgCode === '23505') { + return failure('A credential with this source already exists', 'conflict') + } + if (pgCode === '23503') { + return failure('Invalid credential reference or membership target', 'validation') + } + if (pgCode === '23514') { + return failure('Credential source data failed validation checks', 'validation') + } + + const errAsRecord = + typeof error === 'object' && error !== null ? (error as Record) : {} + logger.error('Credential create failure details', { + code: pgCode, + detail: errAsRecord.detail, + constraint: errAsRecord.constraint, + table: errAsRecord.table, + message: errAsRecord.message, + }) + logger.error('Failed to create credential', { error }) + return failure('Internal server error', 'internal') + } +} + +/** HTTP status for a credential orchestration failure, shared by every route surface. */ +export function statusForCredentialOrchestrationError( + code: CredentialOrchestrationErrorCode | undefined, + options: { providerUnavailable?: boolean } = {} +): number { + if (options.providerUnavailable) return 502 + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index a26a190f126..ebe3542c380 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -22,6 +22,13 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { + type PerformCreateCredentialParams, + type PerformCreateCredentialResult, + performCreateCredential, + statusForCredentialOrchestrationError, +} from './credential-create' + export type CredentialOrchestrationErrorCode = | 'not_found' | 'forbidden' diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts new file mode 100644 index 00000000000..397e96b9f59 --- /dev/null +++ b/apps/sim/lib/credentials/queries.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { credential, credentialMember } from '@sim/db/schema' +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Workspace-scoped credential reads shared by the session surface and the public + * API, so the visibility rules cannot drift between them. + */ + +export type CredentialRow = typeof credential.$inferSelect + +export interface VisibleWorkspaceCredential { + id: string + workspaceId: string + type: CredentialRow['type'] + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + envOwnerUserId: string | null + createdBy: string + createdAt: Date + updatedAt: Date + hasServiceAccountKey: boolean + role: 'admin' | 'member' +} + +/** + * The credentials a user may see in a workspace. + * + * Visibility is an explicit `credential_member` row, plus — for workspace + * admins — every shared-type credential, plus the caller's own personal env + * credentials. Encrypted secret material is never selected. + */ +export async function listVisibleWorkspaceCredentials(params: { + workspaceId: string + userId: string + workspaceAccess: Pick + type?: WorkspaceCredentialType + providerId?: string +}): Promise { + const { workspaceId, userId, workspaceAccess, type, providerId } = params + + const whereClauses = [eq(credential.workspaceId, workspaceId)] + if (type) whereClauses.push(eq(credential.type, type)) + if (providerId) whereClauses.push(eq(credential.providerId, providerId)) + + const isWorkspaceAdmin = workspaceAccess.canAdmin + const accessClause = isWorkspaceAdmin + ? or( + isNotNull(credentialMember.id), + inArray(credential.type, SHARED_CREDENTIAL_TYPES), + eq(credential.envOwnerUserId, userId) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, userId)) + + const rows = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + memberRole: credentialMember.role, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active') + ) + ) + .where(and(...whereClauses, accessClause)) + + return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ + ...rest, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + role: + isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + })) +} + +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and(eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId)) + ) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 77cc4392756..092387cea90 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -83,6 +83,34 @@ export async function findActiveFolder( return row ?? null } +/** + * A folder in a workspace's tree regardless of archive state. + * + * {@link findActiveFolder} answers "is this a valid destination"; this answers "does this row + * exist here at all". Delete needs the second question — `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be retried, and + * filtering archived rows out would strand those stragglers. + */ +export async function findFolderInWorkspace( + folderId: string, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + const [row] = await db + .select() + .from(folder) + .where( + and( + eq(folder.id, folderId), + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType) + ) + ) + .limit(1) + + return row ?? null +} + /** * Where a restored resource should land: its original folder when that folder is reachable, * otherwise the workspace root. diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts new file mode 100644 index 00000000000..38dd3592a4d --- /dev/null +++ b/apps/sim/lib/mcp/queries.ts @@ -0,0 +1,59 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { and, desc, eq, isNull } from 'drizzle-orm' + +/** + * Workspace-scoped MCP server reads. The lifecycle functions in + * `lib/mcp/orchestration` cover the write paths; these cover the read paths the + * public API needs without duplicating the scoping predicate per route. + */ + +export type McpServerRow = typeof mcpServers.$inferSelect + +/** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +export async function listWorkspaceMcpServers(params: { + workspaceId: string +}): Promise { + return db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) + .orderBy(desc(mcpServers.createdAt)) +} + +/** A single live MCP server, or null when it does not exist in this workspace. */ +export async function getWorkspaceMcpServer(params: { + workspaceId: string + serverId: string +}): Promise { + const [row] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, params.serverId), + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + return row ?? null +} + +/** + * Whether a row already occupies the deterministic id derived from a workspace + * and URL — soft-deleted rows included, because the create path revives rather + * than inserts alongside them. Lets a caller reject a duplicate registration + * before the upsert in `performCreateMcpServer` overwrites the existing row. + */ +export async function mcpServerIdExists(params: { + workspaceId: string + serverId: string +}): Promise { + const [row] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + return Boolean(row) +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index aa520209e87..d6383133d8f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -197,20 +197,20 @@ export interface PostHogEventMap { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_updated: { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_deleted: { skill_id: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_shared: { diff --git a/apps/sim/lib/skills/orchestration/index.ts b/apps/sim/lib/skills/orchestration/index.ts new file mode 100644 index 00000000000..48bf621ec27 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/index.ts @@ -0,0 +1,12 @@ +export { + type PerformCreateSkillParams, + type PerformDeleteSkillParams, + type PerformSkillResult, + type PerformUpdateSkillParams, + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + type SkillOrchestrationErrorCode, + type SkillWriteSource, + statusForSkillOrchestrationError, +} from './skill-lifecycle' diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts new file mode 100644 index 00000000000..9fd54a68369 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -0,0 +1,347 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { skill } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import type { z } from 'zod' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext } from '@/lib/skills/access' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' + +const logger = createLogger('SkillOrchestration') + +/** + * Single authority for skill create/update/delete. + * + * Before this module the API route owned the create-vs-update split, the + * built-in guard, the per-skill editor check, the field limits (which lived + * only in the route's Zod contract), and the audit — so the copilot's + * `manage_skill`, which calls `upsertSkills` directly, bypassed all of them. + * Every caller now goes through these functions and gets the same rules. + * + * Workspace-level authorization stays with the caller: each surface has already + * established workspace access by the time it gets here (session middleware, + * the v2 `resolveWorkspaceAccess`, the copilot's permission context). What is + * owned here is everything *per skill*. + */ + +/** + * Skills need a `forbidden` outcome the shared code set does not carry: a + * caller can hold workspace write and still not be an editor of a given skill. + */ +export type SkillOrchestrationErrorCode = OrchestrationErrorCode | 'forbidden' + +/** HTTP status for a skill orchestration failure, shared by every route surface. */ +export function statusForSkillOrchestrationError( + code: SkillOrchestrationErrorCode | undefined +): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} + +type SkillRow = typeof skill.$inferSelect + +/** Which surface performed the write. Recorded on the audit entry and the analytics event. */ +export type SkillWriteSource = 'settings' | 'tool_input' | 'api' + +interface ActorMetadata { + actorName?: string | null + actorEmail?: string | null + source?: SkillWriteSource + request?: NextRequest +} + +export interface PerformCreateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + name: string + description: string + content: string +} + +export interface PerformUpdateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string + name?: string + description?: string + content?: string +} + +export interface PerformDeleteSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string +} + +export interface PerformSkillResult { + success: boolean + error?: string + errorCode?: SkillOrchestrationErrorCode + skill?: SkillRow +} + +function validationFailure(error: string): PerformSkillResult { + return { success: false, error, errorCode: 'validation' } +} + +/** First message from a failed field parse, or null when the value is valid. */ +function fieldError(schema: z.ZodType, value: unknown): string | null { + const parsed = schema.safeParse(value) + return parsed.success ? null : (parsed.error.issues[0]?.message ?? 'Invalid value') +} + +/** + * A workspace skill sharing a built-in's name silently shadows it everywhere the + * two lists are merged. Reject the collision at the write instead of resolving + * it at every read. + */ +function builtinNameCollision(name: string): string | null { + return getBuiltinSkillByName(name) + ? `The skill name "${name}" is reserved by a built-in skill` + : null +} + +/** + * Resolves the acting user's edit rights over an existing workspace skill. + * Returns the loaded row, or the failure to surface. + */ +async function resolveEditableSkill(params: { + workspaceId: string + userId: string + skillId: string +}): Promise<{ ok: true; skill: SkillRow } | { ok: false; result: PerformSkillResult }> { + if (isBuiltinSkillId(params.skillId)) { + return { + ok: false, + result: validationFailure('Built-in skills are read-only and cannot be modified'), + } + } + + const actor = await getSkillActorContext(params.skillId, params.userId) + if (!actor.skill || actor.skill.workspaceId !== params.workspaceId || !actor.hasWorkspaceAccess) { + return { + ok: false, + result: { success: false, error: 'Skill not found', errorCode: 'not_found' }, + } + } + if (!actor.canEdit) { + return { + ok: false, + result: { + success: false, + error: `Skill editor access required to modify "${actor.skill.name}"`, + errorCode: 'forbidden', + }, + } + } + return { ok: true, skill: actor.skill } +} + +/** + * `upsertSkills` reports name collisions and vanished ids as thrown Errors. + * Classify them rather than letting every caller re-match the message. + */ +function classifyUpsertError(error: unknown): PerformSkillResult { + const message = getErrorMessage(error, 'Failed to save skill') + if (message.includes('is unavailable')) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message.startsWith('Skill not found')) { + return { success: false, error: 'Skill not found', errorCode: 'not_found' } + } + logger.error('Skill upsert failed', { error: message }) + return { success: false, error: 'Failed to save skill', errorCode: 'internal' } +} + +type SkillLifecycleAction = 'created' | 'updated' | 'deleted' + +const AUDIT_ACTION = { + created: AuditAction.SKILL_CREATED, + updated: AuditAction.SKILL_UPDATED, + deleted: AuditAction.SKILL_DELETED, +} as const satisfies Record + +const AUDIT_VERB = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', +} as const satisfies Record + +function recordSkillEvent(params: { + action: SkillLifecycleAction + workspaceId: string + userId: string + skillId: string + skillName: string + actor: ActorMetadata +}): void { + const { action, workspaceId, userId, skillId, skillName, actor } = params + + recordAudit({ + workspaceId, + actorId: userId, + actorName: actor.actorName ?? undefined, + actorEmail: actor.actorEmail ?? undefined, + action: AUDIT_ACTION[action], + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + resourceName: skillName, + description: `${AUDIT_VERB[action]} skill "${skillName}"`, + metadata: { source: actor.source }, + request: actor.request, + }) + + // The delete event carries no skill_name — the skill no longer exists to name. + if (action === 'deleted') { + captureServerEvent( + userId, + 'skill_deleted', + { skill_id: skillId, workspace_id: workspaceId, source: actor.source }, + { groups: { workspace: workspaceId } } + ) + return + } + + captureServerEvent( + userId, + action === 'created' ? 'skill_created' : 'skill_updated', + { + skill_id: skillId, + skill_name: skillName, + workspace_id: workspaceId, + source: actor.source, + }, + { groups: { workspace: workspaceId } } + ) +} + +export async function performCreateSkill( + params: PerformCreateSkillParams +): Promise { + const invalid = + fieldError(skillNameSchema, params.name) ?? + fieldError(skillDescriptionSchema, params.description) ?? + fieldError(skillContentSchema, params.content) ?? + builtinNameCollision(params.name) + if (invalid) return validationFailure(invalid) + + let created: { id: string; name: string } | undefined + try { + const { touched } = await upsertSkills({ + skills: [{ name: params.name, description: params.description, content: params.content }], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + created = touched[0] + } catch (error) { + return classifyUpsertError(error) + } + + if (!created) { + logger.error('Skill create returned no touched row', { workspaceId: params.workspaceId }) + return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + } + + recordSkillEvent({ + action: 'created', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: created.id, + skillName: created.name, + actor: params, + }) + + const row = await getSkillById({ skillId: created.id, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + return { success: true, skill: row } +} + +export async function performUpdateSkill( + params: PerformUpdateSkillParams +): Promise { + if ( + params.name === undefined && + params.description === undefined && + params.content === undefined + ) { + return validationFailure('At least one of name, description, or content is required') + } + + const invalid = + (params.name !== undefined + ? (fieldError(skillNameSchema, params.name) ?? builtinNameCollision(params.name)) + : null) ?? + (params.description !== undefined + ? fieldError(skillDescriptionSchema, params.description) + : null) ?? + (params.content !== undefined ? fieldError(skillContentSchema, params.content) : null) + if (invalid) return validationFailure(invalid) + + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + try { + await upsertSkills({ + skills: [ + { + id: params.skillId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.content !== undefined ? { content: params.content } : {}), + }, + ], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + } catch (error) { + return classifyUpsertError(error) + } + + const row = await getSkillById({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'updated', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: row.id, + skillName: row.name, + actor: params, + }) + + return { success: true, skill: row } +} + +export async function performDeleteSkill( + params: PerformDeleteSkillParams +): Promise { + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!deleted) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'deleted', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: params.skillId, + skillName: resolved.skill.name, + actor: params, + }) + + return { success: true, skill: resolved.skill } +} diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 8fbccef1b43..3918deff4e6 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -128,6 +128,57 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * Workspace-scoped reads and deletes. + * + * The functions above tolerate legacy personal tools (`workspace_id IS NULL`, + * owned by one user) alongside workspace ones. The public API is workspace- + * scoped in every direction, so it uses these instead — a caller holding a + * workspace key must never reach another user's personal tool. + */ +export async function listWorkspaceCustomTools(params: { workspaceId: string }) { + return db + .select() + .from(customTools) + .where(eq(customTools.workspaceId, params.workspaceId)) + .orderBy(desc(customTools.createdAt)) +} + +export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { + const [row] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .limit(1) + return row ?? null +} + +/** Titles are unique per workspace (`custom_tools_workspace_title_unique`). */ +export async function getWorkspaceCustomToolByTitle(params: { + workspaceId: string + title: string +}) { + const [row] = await db + .select() + .from(customTools) + .where( + and(eq(customTools.workspaceId, params.workspaceId), eq(customTools.title, params.title)) + ) + .limit(1) + return row ?? null +} + +export async function deleteWorkspaceCustomTool(params: { + workspaceId: string + toolId: string +}): Promise { + const deleted = await db + .delete(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning({ id: customTools.id }) + return deleted.length > 0 +} + export async function getCustomToolById(params: { toolId: string userId: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0cea7a4ec91..137f60f185b 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: 1028, - zodRoutes: 1028, + totalRoutes: 1038, + zodRoutes: 1038, nonZodRoutes: 0, } as const diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 3387f058cb9..5fa5ad826a5 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -38,6 +38,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] /** Extra non-v2 contracts that are documented in the core spec. */ From 11e3ece48991a9ef9b27332e9427b425e8eb527b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 22:57:49 -0700 Subject: [PATCH 2/6] fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping --- apps/docs/openapi-v2-resources.json | 4 +-- apps/sim/app/api/v2/credentials/route.test.ts | 29 +++++++++++++++++++ apps/sim/app/api/v2/credentials/route.ts | 13 +++++++-- .../sim/app/api/v2/custom-tools/[id]/route.ts | 5 +++- .../sim/app/api/v2/custom-tools/route.test.ts | 11 +++++++ apps/sim/app/api/v2/custom-tools/route.ts | 5 +++- apps/sim/app/api/v2/custom-tools/utils.ts | 19 +++++++++++- .../app/api/v2/mcp-servers/[id]/route.test.ts | 26 +++++++++++++++++ apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 18 ++++++++++++ apps/sim/app/api/v2/skills/[id]/route.test.ts | 20 +++++++++++++ apps/sim/app/api/v2/skills/[id]/route.ts | 12 ++++++-- 11 files changed, 152 insertions(+), 10 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 56ecc75a8d9..ae52386cc04 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -275,7 +275,7 @@ "patch": { "operationId": "updateMcpServer", "summary": "Update MCP Server", - "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\nChanging `url`, the auth type, or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", + "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", "tags": ["MCP Servers"], "x-codeSamples": [ { @@ -2173,7 +2173,7 @@ "type": "string", "minLength": 1, "maxLength": 2048, - "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references." + "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL." }, "authType": { "type": "string", "enum": ["none", "headers", "oauth"] }, "headers": { diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index c84b4414dfb..d9fa30535a9 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,12 +13,14 @@ const { mockCheckWorkspaceAccess, mockListVisibleWorkspaceCredentials, mockPerformCreateCredential, + mockGetCredentialActorContext, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockListVisibleWorkspaceCredentials: vi.fn(), mockPerformCreateCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -38,6 +40,10 @@ vi.mock('@/lib/credentials/orchestration', () => ({ performCreateCredential: mockPerformCreateCredential, })) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) @@ -210,6 +216,7 @@ describe('POST /api/v2/credentials', () => { credential: buildRow(), created: true, }) + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -279,6 +286,28 @@ describe('POST /api/v2/credentials', () => { expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') }) + it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: false, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false }) + + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential.role).toBe('member') + }) + + it('reports admin for a fresh insert without a second access lookup', async () => { + const res = await callCreate(VALID_BODY) + + expect((await res.json()).data.credential.role).toBe('admin') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + it('creates the credential and never echoes the submitted secret', async () => { const res = await callCreate({ workspaceId: WORKSPACE_ID, diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index c9ab02f5995..232110187c1 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -8,6 +8,7 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { performCreateCredential } from '@/lib/credentials/orchestration' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -119,10 +120,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } /** - * The creator is always an admin of the credential they just made, whether - * the row was inserted now or matched an existing source. + * A fresh insert makes the creator an admin, but an idempotent match against + * an existing source does not — the orchestration admits a caller who is + * only a *member* of that credential. Resolve the real role rather than + * assuming the create case, or the response would advertise administrative + * actions the caller cannot perform. */ - const credential = toV2CredentialRow(result.credential, 'admin') + const actor = result.created + ? { isAdmin: true } + : await getCredentialActorContext(result.credential.id, userId) + const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member') /** * Always 201, including when an existing credential already occupied this diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index 5ebf1d5ad97..53283b2180b 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -17,7 +17,7 @@ import { upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -144,6 +144,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + logger.error(`[${requestId}] Error updating custom tool`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index 05e3bf45f30..d51dea839b8 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -227,6 +227,17 @@ describe('POST /api/v2/custom-tools', () => { expect(mockUpsertCustomTools).not.toHaveBeenCalled() }) + it('409s when a concurrent create loses the title race inside the lib', async () => { + mockUpsertCustomTools.mockRejectedValue( + new Error('A tool with the title "v2_smoke_tool" already exists in this workspace') + ) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + it('creates the tool and returns 201 with the single tool', async () => { const res = await callCreate(VALID_BODY) const body = await res.json() diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index e28332c4193..b746b285cc2 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -15,7 +15,7 @@ import { upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, @@ -125,6 +125,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + logger.error(`[${requestId}] Error creating custom tool`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts index ccec5df4c1c..1dd0e0b8630 100644 --- a/apps/sim/app/api/v2/custom-tools/utils.ts +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -1,7 +1,24 @@ import type { customTools } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextResponse } from 'next/server' import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' +import { v2Error } from '@/app/api/v2/lib/response' -/** Shared serialization for the v2 custom tool surface. */ +/** Shared serialization + error mapping for the v2 custom tool surface. */ + +/** + * `upsertCustomTools` reports a title collision as a thrown Error, and the unique + * index behind it fires on the race the pre-check cannot cover (two concurrent + * creates of the same title both pass the check, then one insert loses). Classify + * it as a conflict so that race surfaces as 409 rather than a generic 500. + */ +export function v2CustomToolWriteError(error: unknown): NextResponse | null { + const message = getErrorMessage(error, '') + if (/already exists in this workspace/i.test(message)) { + return v2Error('CONFLICT', message) + } + return null +} type CustomToolRow = typeof customTools.$inferSelect diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts index 7acbeefa40d..1d5b93a53de 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -240,6 +240,32 @@ describe('PATCH /api/v2/mcp-servers/[id]', () => { expect((await res.json()).error.code).toBe('NOT_FOUND') }) + it('400s when the url is changed, since the id is derived from it', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://different.example.com/sse', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('url cannot be changed') + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('allows a url that matches the stored one, so a full-object PATCH still works', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://mcp.example.com/sse', + enabled: false, + }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateMcpServer).toHaveBeenCalled() + }) + it('updates the server and returns the public shape', async () => { const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) const body = await res.json() diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index dde71139693..22231ef8eba 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -91,6 +91,24 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) + /** + * A server's id is the hash of its workspace + URL, and this surface promises + * that identity. The lib will happily move `url` while the id keeps hashing + * the old one, which both breaks that promise and defeats the duplicate + * check on create (id-keyed, so it would not see the moved URL) — leaving two + * rows on one URL. Re-pointing a server at a different URL is a new server. + */ + if (body.url !== undefined) { + const current = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!current) return v2Error('NOT_FOUND', 'MCP server not found') + if (current.url !== body.url) { + return v2Error( + 'BAD_REQUEST', + 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' + ) + } + } + const result = await performUpdateMcpServer({ workspaceId, userId, diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts index 88a2a33ac72..834191497ff 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -227,6 +227,16 @@ describe('PATCH /api/v2/skills/[id]', () => { expect((await res.json()).error.message).toContain('Built-in') }) + it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => { + await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + it('updates the skill and returns the single skill', async () => { const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) const body = await res.json() @@ -295,6 +305,16 @@ describe('DELETE /api/v2/skills/[id]', () => { expect(res.status).toBe(400) }) + it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + it('deletes the skill and acknowledges the id', async () => { const res = await callDelete() expect(res.status).toBe(200) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts index 9fa957964dd..2cb7d1f2017 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -88,7 +88,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const { id } = parsed.data.params const { workspaceId, name, description, content } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + /** + * Editing an existing skill is gated per skill, not per workspace: an + * explicit editor grant (or workspace admin) is the authority, and + * `performUpdateSkill` enforces it. Requiring workspace `write` here would + * reject a legitimate skill editor who only holds `read` — stricter than the + * UI and than what this endpoint documents. Creating still needs `write`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) const result = await performUpdateSkill({ @@ -136,7 +143,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou const { id } = parsed.data.params const { workspaceId } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + // Gated per skill by `performDeleteSkill`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) const result = await performDeleteSkill({ From d93dab40e2fd0bb802f3c36a91ad76197a4665d7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:07:56 -0700 Subject: [PATCH 3/6] fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts --- apps/docs/openapi-v2-resources.json | 7 +-- .../app/api/v2/credentials/[id]/route.test.ts | 48 +++++++++++++++++++ apps/sim/app/api/v2/credentials/[id]/route.ts | 27 +++++++++-- .../sim/app/api/v2/custom-tools/route.test.ts | 12 +++++ apps/sim/app/api/v2/custom-tools/utils.ts | 18 +++++-- 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index ae52386cc04..d2683950f38 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -1601,7 +1601,7 @@ "patch": { "operationId": "updateCredential", "summary": "Update Credential", - "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response.", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1673,13 +1673,14 @@ "404": { "$ref": "#/components/responses/NotFound" }, "409": { "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "500": { "$ref": "#/components/responses/InternalError" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } } }, "delete": { "operationId": "deleteCredential", "summary": "Delete Credential", - "description": "Delete a credential. Requires credential admin. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.", + "description": "Delete a credential. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", "tags": ["Credentials"], "x-codeSamples": [ { diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts index 2d6799509cd..ac8e06445e6 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -188,6 +188,7 @@ describe('PATCH /api/v2/credentials/[id]', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) mockPerformUpdateCredential.mockResolvedValue({ success: true }) }) @@ -246,6 +247,35 @@ describe('PATCH /api/v2/credentials/[id]', () => { expect((await res.json()).error.code).toBe('FORBIDDEN') }) + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('503s when the provider is unreachable during a secret rotation', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + it('rotates a secret without echoing it back', async () => { const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) const body = await res.json() @@ -268,6 +298,7 @@ describe('DELETE /api/v2/credentials/[id]', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) mockPerformDeleteCredential.mockResolvedValue({ success: true }) }) @@ -309,6 +340,23 @@ describe('DELETE /api/v2/credentials/[id]', () => { expect(mockPerformDeleteCredential).not.toHaveBeenCalled() }) + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + it('deletes the credential and acknowledges the id', async () => { const res = await callDelete() expect(res.status).toBe(200) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts index 975e5e9c9fe..ec67510c94c 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -101,7 +101,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const { id } = parsed.data.params const { workspaceId, ...changes } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + /** + * Credential mutations are gated per credential, not per workspace: + * `performUpdateCredential` requires credential admin, and the internal + * surface applies no workspace-level bar at all. Requiring workspace `write` + * here would lock out a credential admin who only holds `read`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) // Tenant-scope the id before the orchestration re-derives access from the @@ -109,12 +115,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) if (!result.success) { return v2CredentialOrchestrationError( result.errorCode, - result.error ?? 'Failed to update credential' + result.error ?? 'Failed to update credential', + { providerUnavailable: result.providerErrorCode === 'provider_unavailable' } ) } @@ -151,12 +161,23 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou const { id } = parsed.data.params const { workspaceId } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + // Gated per credential by `performDeleteCredential`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + /** + * A credential the caller cannot see answers 404, matching GET, so a + * workspace member cannot tell an inaccessible credential from a missing one + * and enumerate ids. A credential they *can* see but cannot administer still + * gets the orchestration's 403 — that distinction is not a leak, since GET + * already shows them the credential. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + const result = await performDeleteCredential({ credentialId: id, userId, request }) if (!result.success) { return v2CredentialOrchestrationError( diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index d51dea839b8..5693e018448 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -238,6 +238,18 @@ describe('POST /api/v2/custom-tools', () => { expect((await res.json()).error.code).toBe('CONFLICT') }) + it('409s when the unique index rejects the loser of a title race', async () => { + const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + mockUpsertCustomTools.mockRejectedValue(pgError) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + it('creates the tool and returns 201 with the single tool', async () => { const res = await callCreate(VALID_BODY) const body = await res.json() diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts index 1dd0e0b8630..516101065ad 100644 --- a/apps/sim/app/api/v2/custom-tools/utils.ts +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -1,5 +1,5 @@ import type { customTools } from '@sim/db/schema' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import type { NextResponse } from 'next/server' import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' import { v2Error } from '@/app/api/v2/lib/response' @@ -7,12 +7,20 @@ import { v2Error } from '@/app/api/v2/lib/response' /** Shared serialization + error mapping for the v2 custom tool surface. */ /** - * `upsertCustomTools` reports a title collision as a thrown Error, and the unique - * index behind it fires on the race the pre-check cannot cover (two concurrent - * creates of the same title both pass the check, then one insert loses). Classify - * it as a conflict so that race surfaces as 409 rather than a generic 500. + * Classifies a title collision as a conflict so it surfaces as 409 rather than a + * generic 500. Two distinct failures reach here and both must be covered: + * + * - `upsertCustomTools` throws its own message when its in-transaction duplicate + * `SELECT` finds one. + * - Under a concurrent create or rename, both callers pass that `SELECT` too, and + * the loser is rejected by `custom_tools_workspace_title_unique` as a raw + * Postgres `23505` — whose message matches nothing, which is exactly the race + * the message check alone cannot see. */ export function v2CustomToolWriteError(error: unknown): NextResponse | null { + if (getPostgresErrorCode(error) === '23505') { + return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace') + } const message = getErrorMessage(error, '') if (/already exists in this workspace/i.test(message)) { return v2Error('CONFLICT', message) From b5f0d01631dd17df4fae81a29484073bc9be73d9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:21:02 -0700 Subject: [PATCH 4/6] fix(api): close unique-violation, revival, orphan-write, and env-rename gaps --- .../app/api/v2/credentials/[id]/route.test.ts | 19 ++++++++ apps/sim/app/api/v2/credentials/[id]/route.ts | 16 +++++++ .../api/v2/custom-tools/[id]/route.test.ts | 48 ++++++++++--------- .../sim/app/api/v2/custom-tools/[id]/route.ts | 20 +++----- apps/sim/app/api/v2/mcp-servers/route.test.ts | 24 ++++++++-- apps/sim/app/api/v2/mcp-servers/route.ts | 19 ++++++-- apps/sim/lib/mcp/queries.ts | 20 ++++---- .../skills/orchestration/skill-lifecycle.ts | 14 +++++- .../lib/workflows/custom-tools/operations.ts | 29 +++++++++++ 9 files changed, 154 insertions(+), 55 deletions(-) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts index ac8e06445e6..eb46d396176 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -276,6 +276,25 @@ describe('PATCH /api/v2/credentials/[id]', () => { expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') }) + it('rejects a displayName rename on an env credential instead of dropping it', async () => { + mockGetWorkspaceCredential.mockResolvedValue( + buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' }) + ) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('envKey') + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('still allows a description change on an env credential', async () => { + mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' })) + const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateCredential).toHaveBeenCalled() + }) + it('rotates a secret without echoing it back', async () => { const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) const body = await res.json() diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts index ec67510c94c..2efc004ad1d 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -118,6 +118,22 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const actor = await getCredentialActorContext(id, userId) if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + /** + * An env credential's display name IS its `envKey` — the lib only applies + * `displayName` to `oauth` and `service_account`, so accepting it here would + * either drop the rename silently (when sent alongside `description`) or + * fail with an unrelated environment-editor message (when sent alone). + */ + if ( + changes.displayName !== undefined && + (existing.type === 'env_workspace' || existing.type === 'env_personal') + ) { + return v2Error( + 'BAD_REQUEST', + 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.' + ) + } + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) if (!result.success) { diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index 22bccd11209..5619a64b1cd 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -14,14 +14,14 @@ const { mockGetWorkspaceCustomTool, mockGetWorkspaceCustomToolByTitle, mockDeleteWorkspaceCustomTool, - mockUpsertCustomTools, + mockUpdateWorkspaceCustomTool, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockGetWorkspaceCustomTool: vi.fn(), mockGetWorkspaceCustomToolByTitle: vi.fn(), mockDeleteWorkspaceCustomTool: vi.fn(), - mockUpsertCustomTools: vi.fn(), + mockUpdateWorkspaceCustomTool: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -33,7 +33,7 @@ vi.mock('@/lib/workflows/custom-tools/operations', () => ({ getWorkspaceCustomTool: mockGetWorkspaceCustomTool, getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, - upsertCustomTools: mockUpsertCustomTools, + updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool, })) vi.mock('@/app/api/v2/lib/gate', () => ({ @@ -175,7 +175,7 @@ describe('PATCH /api/v2/custom-tools/[id]', () => { mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) - mockUpsertCustomTools.mockResolvedValue([buildTool()]) + mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool()) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -186,20 +186,20 @@ describe('PATCH /api/v2/custom-tools/[id]', () => { const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) expect(res.status).toBe(404) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() }) it('400s when no field to change is supplied', async () => { const res = await callPatch({ workspaceId: 'workspace-1' }) expect(res.status).toBe(400) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() }) it('surfaces an access-denied failure in the v2 error envelope', async () => { mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) expect(res.status).toBe(403) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { @@ -213,7 +213,7 @@ describe('PATCH /api/v2/custom-tools/[id]', () => { mockGetWorkspaceCustomTool.mockResolvedValue(null) const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) expect(res.status).toBe(404) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() }) it('409s when renaming onto an existing title', async () => { @@ -223,27 +223,29 @@ describe('PATCH /api/v2/custom-tools/[id]', () => { expect(res.status).toBe(409) expect((await res.json()).error.code).toBe('CONFLICT') - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() }) it('merges the partial body against the stored tool', async () => { const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) expect(res.status).toBe(200) - expect(mockUpsertCustomTools).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - tools: [ - { - id: 'tool_abc123', - title: 'lookup_order', - schema: TOOL_SCHEMA, - code: 'return 2', - }, - ], - }) - ) + expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return 2', + }) + }) + + it('404s rather than orphaning a tool deleted between the read and the write', async () => { + mockUpdateWorkspaceCustomTool.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index 53283b2180b..e793c31c3ea 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -14,7 +14,7 @@ import { deleteWorkspaceCustomTool, getWorkspaceCustomTool, getWorkspaceCustomToolByTitle, - upsertCustomTools, + updateWorkspaceCustomTool, } from '@/lib/workflows/custom-tools/operations' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' @@ -114,21 +114,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } } - await upsertCustomTools({ - tools: [ - { - id, - title: title ?? current.title, - schema: schema ?? current.schema, - code: code ?? current.code, - }, - ], + const updated = await updateWorkspaceCustomTool({ workspaceId, - userId, - requestId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, }) - - const updated = await getWorkspaceCustomTool({ workspaceId, toolId: id }) if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') recordAudit({ diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts index 1704df7b771..f9893f60c8e 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -14,14 +14,14 @@ const { mockResolveWorkspaceAccess, mockListWorkspaceMcpServers, mockGetWorkspaceMcpServer, - mockMcpServerIdExists, + mockGetMcpServerIdState, mockPerformCreateMcpServer, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockListWorkspaceMcpServers: vi.fn(), mockGetWorkspaceMcpServer: vi.fn(), - mockMcpServerIdExists: vi.fn(), + mockGetMcpServerIdState: vi.fn(), mockPerformCreateMcpServer: vi.fn(), })) @@ -33,7 +33,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ vi.mock('@/lib/mcp/queries', () => ({ listWorkspaceMcpServers: mockListWorkspaceMcpServers, getWorkspaceMcpServer: mockGetWorkspaceMcpServer, - mcpServerIdExists: mockMcpServerIdExists, + getMcpServerIdState: mockGetMcpServerIdState, })) vi.mock('@/lib/mcp/orchestration', () => ({ @@ -204,7 +204,7 @@ describe('POST /api/v2/mcp-servers', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockMcpServerIdExists.mockResolvedValue(false) + mockGetMcpServerIdState.mockResolvedValue(null) mockPerformCreateMcpServer.mockResolvedValue({ success: true, serverId: 'mcp-abc12345', @@ -269,7 +269,7 @@ describe('POST /api/v2/mcp-servers', () => { }) it('409s on a duplicate URL without letting the lib upsert', async () => { - mockMcpServerIdExists.mockResolvedValue(true) + mockGetMcpServerIdState.mockResolvedValue({ deleted: false }) const res = await callCreate(VALID_BODY) @@ -291,6 +291,20 @@ describe('POST /api/v2/mcp-servers', () => { expect((await res.json()).error.code).toBe('CONFLICT') }) + it('revives a soft-deleted URL instead of stranding it behind a 409', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: true }) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(201) + expect(mockPerformCreateMcpServer).toHaveBeenCalled() + }) + it('creates the server and returns 201 with the public shape', async () => { const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) const body = await res.json() diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 96dc3531d55..73a18037501 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -10,9 +10,9 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performCreateMcpServer } from '@/lib/mcp/orchestration' import { + getMcpServerIdState, getWorkspaceMcpServer, listWorkspaceMcpServers, - mcpServerIdExists, } from '@/lib/mcp/queries' import { generateMcpServerId } from '@/lib/mcp/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' @@ -106,14 +106,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * same URL silently overwrites the first. The internal surface and the * copilot rely on that; a public create must not, so the collision is * detected here, before the lib is given a chance to clobber the row. + * + * Only a *live* row is a conflict. A soft-deleted one is revived by the lib + * rather than inserted alongside, and reporting it as a duplicate would + * strand that URL for good: the detail routes resolve live rows only, so it + * could be neither fetched, patched, nor re-created. */ const serverId = generateMcpServerId(workspaceId, body.url) - if (await mcpServerIdExists({ workspaceId, serverId })) { + const idState = await getMcpServerIdState({ workspaceId, serverId }) + if (idState && !idState.deleted) { return v2Error( 'CONFLICT', 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' ) } + const revivingSoftDeleted = idState?.deleted === true const result = await performCreateMcpServer({ workspaceId, @@ -138,8 +145,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') } - // A concurrent create won the id race and the lib upserted onto it. - if (result.updated) { + /** + * `updated` means the lib wrote onto an existing row. Reviving the + * soft-deleted row we already saw is the intended outcome; otherwise a + * concurrent create won the id race between the check above and the write. + */ + if (result.updated && !revivingSoftDeleted) { return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') } diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 38dd3592a4d..81a50f0b1d6 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -41,19 +41,23 @@ export async function getWorkspaceMcpServer(params: { } /** - * Whether a row already occupies the deterministic id derived from a workspace - * and URL — soft-deleted rows included, because the create path revives rather - * than inserts alongside them. Lets a caller reject a duplicate registration - * before the upsert in `performCreateMcpServer` overwrites the existing row. + * The state of the row occupying the deterministic id derived from a workspace + * and URL, or null when the id is free. + * + * The soft-deleted case has to be distinguished rather than merged into "taken": + * `performCreateMcpServer` revives such a row instead of inserting alongside it, + * so reporting it as a duplicate would make a soft-deleted URL permanently + * unusable — it cannot be fetched or patched either, since those resolve live + * rows only. */ -export async function mcpServerIdExists(params: { +export async function getMcpServerIdState(params: { workspaceId: string serverId: string -}): Promise { +}): Promise<{ deleted: boolean } | null> { const [row] = await db - .select({ id: mcpServers.id }) + .select({ deletedAt: mcpServers.deletedAt }) .from(mcpServers) .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))) .limit(1) - return Boolean(row) + return row ? { deleted: row.deletedAt !== null } : null } diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index 9fd54a68369..b45d6b7db75 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import type { skill } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import type { z } from 'zod' import { @@ -151,9 +151,21 @@ async function resolveEditableSkill(params: { /** * `upsertSkills` reports name collisions and vanished ids as thrown Errors. * Classify them rather than letting every caller re-match the message. + * + * The `23505` arm covers the race its in-transaction name `SELECT` cannot: two + * concurrent creates (or renames) both pass that check, and the loser is rejected + * by `skill_workspace_name_unique` as a raw Postgres error whose message matches + * nothing here — which would otherwise surface as a 500 for what is a conflict. */ function classifyUpsertError(error: unknown): PerformSkillResult { const message = getErrorMessage(error, 'Failed to save skill') + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'That skill name is unavailable in this workspace', + errorCode: 'conflict', + } + } if (message.includes('is unavailable')) { return { success: false, error: message, errorCode: 'conflict' } } diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 3918deff4e6..2b6a779776b 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -168,6 +168,35 @@ export async function getWorkspaceCustomToolByTitle(params: { return row ?? null } +/** + * Updates a workspace tool in place, returning the updated row or null when the + * id no longer resolves in that workspace. + * + * Deliberately not `upsertCustomTools`: that treats an unresolvable id as a + * create and inserts under a *new* id, so a tool deleted concurrently with an + * edit would be silently re-created as an orphan under a different id while the + * caller's follow-up read of the original id 404s. + */ +export async function updateWorkspaceCustomTool(params: { + workspaceId: string + toolId: string + title: string + schema: unknown + code: string +}) { + const [row] = await db + .update(customTools) + .set({ + title: params.title, + schema: params.schema, + code: params.code, + updatedAt: new Date(), + }) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning() + return row ?? null +} + export async function deleteWorkspaceCustomTool(params: { workspaceId: string toolId: string From 5ed0cadf93825d75078b2c6050dd32f77ffae3b1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:31:56 -0700 Subject: [PATCH 5/6] fix(api): treat every provider-outage code as unavailable on create and update --- .../app/api/v2/credentials/[id]/route.test.ts | 35 ++++++++++++++++--- apps/sim/app/api/v2/credentials/[id]/route.ts | 8 +++-- .../orchestration/credential-create.ts | 21 +++++++++-- .../lib/credentials/orchestration/index.ts | 1 + 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts index eb46d396176..b76997daad2 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -36,10 +36,14 @@ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, })) -vi.mock('@/lib/credentials/orchestration', () => ({ - performUpdateCredential: mockPerformUpdateCredential, - performDeleteCredential: mockPerformDeleteCredential, -})) +vi.mock('@/lib/credentials/orchestration', async () => { + const actual = await import('@/lib/credentials/orchestration/credential-create') + return { + isProviderOutageCode: actual.isProviderOutageCode, + performUpdateCredential: mockPerformUpdateCredential, + performDeleteCredential: mockPerformDeleteCredential, + } +}) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), @@ -295,6 +299,29 @@ describe('PATCH /api/v2/credentials/[id]', () => { expect(mockPerformUpdateCredential).toHaveBeenCalled() }) + it('503s on an Atlassian outage too, not just a token-provider one', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'atlassian_unavailable', + errorCode: 'validation', + providerErrorCode: 'atlassian_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('keeps a rejected secret a 400, not a 503', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'invalid_credentials', + errorCode: 'validation', + providerErrorCode: 'invalid_credentials', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(400) + }) + it('rotates a secret without echoing it back', async () => { const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) const body = await res.json() diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts index 2efc004ad1d..d92c016b284 100644 --- a/apps/sim/app/api/v2/credentials/[id]/route.ts +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -10,7 +10,11 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' @@ -140,7 +144,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout return v2CredentialOrchestrationError( result.errorCode, result.error ?? 'Failed to update credential', - { providerUnavailable: result.providerErrorCode === 'provider_unavailable' } + { providerUnavailable: isProviderOutageCode(result.providerErrorCode) } ) } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 57ec7c42e92..07bad9ebfa1 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -512,7 +512,10 @@ export async function performCreateCredential( upstreamStatus: error.status, ...error.logDetail, }) - return failure(error.code, 'validation', { providerErrorCode: error.code }) + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) } if (error instanceof TokenServiceAccountValidationError) { logger.warn(`Token service-account credential rejected: ${error.code}`, { @@ -523,7 +526,7 @@ export async function performCreateCredential( // A provider outage is an infra failure, not a bad request. return failure(error.code, 'validation', { providerErrorCode: error.code, - providerUnavailable: error.code === 'provider_unavailable', + providerUnavailable: isProviderOutageCode(error.code), }) } if (error instanceof DuplicateCredentialError) { @@ -557,6 +560,20 @@ export async function performCreateCredential( } } +/** + * 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 + * its own — Atlassian raises `atlassian_unavailable`, the token service accounts + * raise `provider_unavailable` — and both must map to 503, not 400. Kept as one + * set so a new provider family is added in a single place instead of being + * missed on whichever call path nobody re-checked. + */ +const PROVIDER_OUTAGE_CODES = new Set(['provider_unavailable', 'atlassian_unavailable']) + +export function isProviderOutageCode(code: string | undefined): boolean { + return code !== undefined && PROVIDER_OUTAGE_CODES.has(code) +} + /** HTTP status for a credential orchestration failure, shared by every route surface. */ export function statusForCredentialOrchestrationError( code: CredentialOrchestrationErrorCode | undefined, diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ebe3542c380..e0c6a375e39 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -23,6 +23,7 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') export { + isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, performCreateCredential, From 2b33a0eb620bf971d412b817f73800e969e52380 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:43:29 -0700 Subject: [PATCH 6/6] fix(credentials): use the shared outage predicate on the session update path --- apps/sim/app/api/credentials/[id]/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index c0d72341ed5..1f63035ed85 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,7 +5,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') @@ -101,7 +105,9 @@ export const PUT = withRouteHandler( ? 409 : // A provider outage during reconnect is infra, not a bad // request — mirror the create route and runtime token route. - result.providerErrorCode === 'provider_unavailable' + // Every provider family names its own outage code, so this + // asks the shared predicate rather than matching one literal. + isProviderOutageCode(result.providerErrorCode) ? 502 : result.errorCode === 'validation' ? 400