From 5f01d47832e97c477a49852e7b656f77a514c162 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 20 Aug 2026 14:08:40 -0700 Subject: [PATCH 1/4] feat(api): add v2 resource management endpoints --- apps/docs/content/docs/en/cli/knowledge.mdx | 217 + apps/docs/content/docs/en/cli/reference.mdx | 339 ++ apps/docs/content/docs/en/cli/skills.mdx | 80 + apps/docs/content/docs/en/cli/workspaces.mdx | 18 + apps/docs/openapi-v2-knowledge.json | 3707 ++++++++++++----- apps/docs/openapi-v2-resources.json | 755 +++- apps/sim/app/api/skills/[id]/members/route.ts | 312 +- apps/sim/app/api/v2/credentials/route.test.ts | 78 +- apps/sim/app/api/v2/credentials/route.ts | 9 +- .../[connectorId]/documents/route.ts | 83 + .../[id]/connectors/[connectorId]/route.ts | 82 + .../connectors/[connectorId]/sync/route.ts | 24 + .../knowledge/[id]/connectors/route.test.ts | 324 ++ .../api/v2/knowledge/[id]/connectors/route.ts | 76 + .../app/api/v2/knowledge/connector-utils.ts | 112 + .../api/v2/skills/[id]/editors/route.test.ts | 206 + .../app/api/v2/skills/[id]/editors/route.ts | 128 + apps/sim/app/api/v2/workspaces/route.test.ts | 56 + apps/sim/app/api/v2/workspaces/route.ts | 49 + apps/sim/lib/api/contracts/skills.ts | 25 +- .../v2/__tests__/list-pagination.test.ts | 14 + apps/sim/lib/api/contracts/v2/credentials.ts | 184 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 387 +- .../lib/api/contracts/v2/openapi/knowledge.ts | 325 ++ .../lib/api/contracts/v2/openapi/resources.ts | 147 +- apps/sim/lib/api/contracts/v2/skills.ts | 96 + apps/sim/lib/api/contracts/v2/workspaces.ts | 19 + .../knowledge/application/connectors.test.ts | 5 +- .../lib/knowledge/application/connectors.ts | 57 +- .../knowledge/application/operations.test.ts | 4 + .../application/editor-use-cases.test.ts | 291 ++ .../lib/skills/application/operations.test.ts | 15 + apps/sim/lib/skills/application/operations.ts | 24 + apps/sim/lib/skills/application/use-cases.ts | 216 +- .../list-public-workspaces.test.ts | 95 + .../application/list-public-workspaces.ts | 91 + .../lib/workspaces/application/operations.ts | 6 + .../sim-cli/src/commands/credentials.test.ts | 8 +- packages/sim-cli/src/commands/credentials.ts | 4 +- packages/sim-cli/src/contract/commands.ts | 9 + packages/sim-cli/src/generated/v2-api.ts | 711 +++- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 6 +- 43 files changed, 7915 insertions(+), 1483 deletions(-) create mode 100644 apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/connector-utils.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/editors/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/editors/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/route.ts create mode 100644 apps/sim/lib/skills/application/editor-use-cases.test.ts create mode 100644 apps/sim/lib/workspaces/application/list-public-workspaces.test.ts create mode 100644 apps/sim/lib/workspaces/application/list-public-workspaces.ts diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 90f17671e7b..eeb96dc129e 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -213,6 +213,195 @@ sim knowledge create [options] +## Create knowledge connector + +```bash +sim knowledge connectors create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--connector-type ` | Yes | Registered connector type. | +| `--credential-id ` | No | OAuth credential identifier for connectors that require OAuth. | +| `--api-key ` | No | Write-only API key for connectors that use API-key authentication. | +| `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. | + + + +## Delete knowledge connector + +```bash +sim knowledge connectors delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--delete-documents` | No | Also permanently delete documents produced by this connector. | +| `--no-delete-documents` | No | Send --delete-documents as false. | + + + +## Get knowledge connector + +```bash +sim knowledge connectors get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +## List knowledge connector documents + +```bash +sim knowledge connectors documents list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-excluded` | No | Include documents explicitly excluded by a user. | +| `--no-include-excluded` | No | Send --include-excluded as false. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## List knowledge connectors + +```bash +sim knowledge connectors list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## Update knowledge connector + +```bash +sim knowledge connectors update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | +| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | + + + +## Update knowledge connector documents + +```bash +sim knowledge connectors documents update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | + + + ## Create a knowledge folder at a path ```bash @@ -398,6 +587,34 @@ sim knowledge search [options] +## Sync knowledge connector + +```bash +sim knowledge sync create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | +| `--no-rehydrate` | No | Send --rehydrate as false. | + + + ## Update knowledge base ```bash diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a0f59c28f6d..c29a1c5c057 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -1173,6 +1173,209 @@ sim knowledge create [options] +### sim knowledge connectors create + +Create Knowledge Connector + +```bash +sim knowledge connectors create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--connector-type ` | Yes | Registered connector type. | +| `--credential-id ` | No | OAuth credential identifier for connectors that require OAuth. | +| `--api-key ` | No | Write-only API key for connectors that use API-key authentication. | +| `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. | + + + +### sim knowledge connectors delete + +Delete Knowledge Connector + +```bash +sim knowledge connectors delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--delete-documents` | No | Also permanently delete documents produced by this connector. | +| `--no-delete-documents` | No | Send --delete-documents as false. | + + + +### sim knowledge connectors get + +Get Knowledge Connector + +```bash +sim knowledge connectors get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +### sim knowledge connectors documents list + +List Knowledge Connector Documents + +```bash +sim knowledge connectors documents list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-excluded` | No | Include documents explicitly excluded by a user. | +| `--no-include-excluded` | No | Send --include-excluded as false. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge connectors list + +List Knowledge Connectors + +```bash +sim knowledge connectors list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique knowledge base identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim knowledge connectors update + +Update Knowledge Connector + +```bash +sim knowledge connectors update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source-config ` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | +| `--sync-interval-minutes ` | No | New scheduled synchronization interval in minutes. | +| `--status ` | No | New connector state. Accepted values: `active`, `paused`. | + + + +### sim knowledge connectors documents update + +Update Knowledge Connector Documents + +```bash +sim knowledge connectors documents update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | + + + ### sim knowledge folders create Create a knowledge folder at a path @@ -1376,6 +1579,36 @@ sim knowledge search [options] +### sim knowledge sync create + +Sync Knowledge Connector + +```bash +sim knowledge sync create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Knowledge base that owns the connector. | +| `connectorId` | Yes | Connector selected for the operation. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--rehydrate` | No | Re-fetch and re-index every existing connector document. | +| `--no-rehydrate` | No | Send --rehydrate as false. | + + + ### sim knowledge update Update Knowledge Base @@ -1886,6 +2119,92 @@ sim skills get +### sim skills editors create + +Grant Skill Editor + +```bash +sim skills editors create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--email ` | Yes | Email address of a current workspace member. | + + + +### sim skills editors list + +List Skill Editors + +```bash +sim skills editors list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +### sim skills editors delete + +Revoke Skill Editor + +```bash +sim skills editors delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--email ` | Yes | Email address of a current workspace member. | + + + ### sim skills list List Skills @@ -3766,3 +4085,23 @@ sim workspaces members [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + +### sim workspaces list + +List Workspaces + +```bash +sim workspaces list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index a965506ec1c..f63ee7175da 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -69,6 +69,86 @@ sim skills get +## Grant skill editor + +```bash +sim skills editors create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--email ` | Yes | Email address of a current workspace member. | + + + +## List skill editors + +```bash +sim skills editors list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## Revoke skill editor + +```bash +sim skills editors delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--email ` | Yes | Email address of a current workspace member. | + + + ## List skills ```bash diff --git a/apps/docs/content/docs/en/cli/workspaces.mdx b/apps/docs/content/docs/en/cli/workspaces.mdx index 82a36c1300b..7876176f96b 100644 --- a/apps/docs/content/docs/en/cli/workspaces.mdx +++ b/apps/docs/content/docs/en/cli/workspaces.mdx @@ -30,3 +30,21 @@ sim workspaces members [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + +## List workspaces + +```bash +sim workspaces list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 401eb99a249..67feaefebe9 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -471,26 +471,88 @@ } } }, - "/api/v2/knowledge/search": { - "post": { - "operationId": "searchKnowledge", - "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.", + "/api/v2/knowledge/{id}/connectors": { + "get": { + "operationId": "listKnowledgeConnectors", + "summary": "List Knowledge Connectors", + "description": "List external sources connected to a knowledge base with opaque cursor pagination. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], - "requestBody": { - "required": true, - "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SearchKnowledgeRequest" - } + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["connectorType", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum connectors to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum connectors to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { "200": { - "description": "Matching document chunks ordered by relevance.", + "description": "A page of knowledge connectors.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -505,7 +567,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeSearchResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorListResponse" } } } @@ -516,18 +578,12 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -538,13 +594,11 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/{id}/tags": { - "get": { - "operationId": "listKnowledgeTags", - "summary": "List Tags", - "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.", + }, + "post": { + "operationId": "createKnowledgeConnector", + "summary": "Create Knowledge Connector", + "description": "Validate and connect an external source, then queue its initial synchronization. The apiKey field is write-only and is never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -557,22 +611,22 @@ "minLength": 1, "description": "Unique knowledge base identifier." } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the knowledge base.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Workspace that owns the knowledge base." - } } ], + "requestBody": { + "required": true, + "description": "Workspace, connector type, authentication reference, source configuration, and sync schedule.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeConnectorRequest" + } + } + } + }, "responses": { - "200": { - "description": "The knowledge base tag vocabulary.", + "201": { + "description": "The created connector without secret material.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -587,7 +641,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeTagListResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorResponse" } } } @@ -604,6 +658,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -616,132 +676,51 @@ } } }, - "/api/v2/knowledge/{id}/documents": { + "/api/v2/knowledge/{id}/connectors/{connectorId}": { "get": { - "operationId": "listKnowledgeDocuments", - "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.", + "operationId": "getKnowledgeConnector", + "summary": "Get Knowledge Connector", + "description": "Retrieve one connector and its ten most recent synchronization attempts. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." } }, { - "name": "workspaceId", - "in": "query", + "name": "connectorId", + "in": "path", "required": true, - "description": "Workspace that owns the knowledge base.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Workspace that owns the knowledge base." - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 + "description": "Connector selected for the operation." } }, { - "name": "search", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Case-insensitive substring match against the document filename.", + "required": true, + "description": "Workspace that owns the knowledge base.", "schema": { - "description": "Case-insensitive substring match against the document filename.", "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "enabledFilter", - "in": "query", - "required": false, - "description": "Filter by whether documents are enabled for search.", - "schema": { - "default": "all", - "description": "Filter by whether documents are enabled for search.", - "type": "string", - "enum": ["all", "enabled", "disabled"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": [ - "filename", - "fileSize", - "tokenCount", - "chunkCount", - "uploadedAt", - "processingStatus", - "enabled" - ] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "desc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "tagFilters", - "in": "query", - "required": false, - "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", - "schema": { - "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", - "examples": [ - "[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]" - ], - "type": "string" + "maxLength": 128, + "description": "Workspace that owns the knowledge base." } } ], "responses": { "200": { - "description": "A page of knowledge documents.", + "description": "The connector and recent synchronization history.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -756,7 +735,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentListResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorDetailResponse" } } } @@ -785,37 +764,48 @@ } }, "patch": { - "operationId": "bulkUpdateKnowledgeDocuments", - "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "updateKnowledgeConnector", + "summary": "Update Knowledge Connector", + "description": "Update connector source configuration, schedule, or active state. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." + } + }, + { + "name": "connectorId", + "in": "path", + "required": true, + "description": "Connector selected for the operation.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Connector selected for the operation." } } ], "requestBody": { "required": true, - "description": "Operation and the documents it applies to.", + "description": "Workspace scope and at least one mutable connector field.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkUpdateKnowledgeDocumentsRequest" + "$ref": "#/components/schemas/UpdateKnowledgeConnectorRequest" } } } }, "responses": { "200": { - "description": "The number and identifiers of the documents that changed.", + "description": "The updated connector.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -830,7 +820,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorResponse" } } } @@ -847,6 +837,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -861,21 +854,32 @@ } } }, - "post": { - "operationId": "uploadKnowledgeDocument", - "summary": "Upload Document", - "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.", + "delete": { + "operationId": "deleteKnowledgeConnector", + "summary": "Delete Knowledge Connector", + "description": "Delete a connector and optionally its synchronized documents. Documents are retained by default. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." + } + }, + { + "name": "connectorId", + "in": "path", + "required": true, + "description": "Connector selected for the operation.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Connector selected for the operation." } }, { @@ -889,22 +893,21 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." } - } - ], - "requestBody": { - "required": true, - "description": "Multipart form containing the document file.", - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UploadKnowledgeDocumentForm" - } + }, + { + "name": "deleteDocuments", + "in": "query", + "required": false, + "description": "Also permanently delete documents produced by this connector.", + "schema": { + "description": "Also permanently delete documents produced by this connector.", + "type": "boolean" } } - }, + ], "responses": { - "201": { - "description": "The accepted document queued for processing.", + "200": { + "description": "Connector deletion acknowledgement and document counts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -919,7 +922,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentSummaryResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorDeleteResponse" } } } @@ -930,21 +933,12 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -957,39 +951,50 @@ } } }, - "/api/v2/knowledge/{id}/documents/uploads": { + "/api/v2/knowledge/{id}/connectors/{connectorId}/sync": { "post": { - "operationId": "createKnowledgeDocumentUpload", - "summary": "Create Document Upload", - "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.", + "operationId": "syncKnowledgeConnector", + "summary": "Sync Knowledge Connector", + "description": "Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." + } + }, + { + "name": "connectorId", + "in": "path", + "required": true, + "description": "Connector selected for the operation.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Connector selected for the operation." } } ], "requestBody": { "required": true, - "description": "Document metadata used to authorize and initialize the upload.", + "description": "Workspace scope and optional full rehydration control.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadRequest" + "$ref": "#/components/schemas/SyncKnowledgeConnectorRequest" } } } }, "responses": { - "201": { - "description": "The created upload session and transfer instructions.", + "200": { + "description": "Synchronization was queued.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1004,7 +1009,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreateKnowledgeDocumentUploadResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorSyncResponse" } } } @@ -1015,21 +1020,18 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1042,33 +1044,33 @@ } } }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { - "delete": { - "operationId": "abortKnowledgeDocumentUpload", - "summary": "Abort Document Upload", - "description": "Abort an incomplete upload and discard provider-side multipart state.", + "/api/v2/knowledge/{id}/connectors/{connectorId}/documents": { + "get": { + "operationId": "listKnowledgeConnectorDocuments", + "summary": "List Knowledge Connector Documents", + "description": "List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." } }, { - "name": "uploadId", + "name": "connectorId", "in": "path", "required": true, - "description": "Upload session identifier returned when the upload was created.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Upload session identifier returned when the upload was created." + "description": "Connector selected for the operation." } }, { @@ -1084,20 +1086,43 @@ } }, { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "name": "includeExcluded", + "in": "query", + "required": false, + "description": "Include documents explicitly excluded by a user.", + "schema": { + "description": "Include documents explicitly excluded by a user.", + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum connector documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum connector documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "minLength": 1 } } ], "responses": { "200": { - "description": "The aborted upload session.", + "description": "A page of connector documents.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1112,7 +1137,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorDocumentListResponse" } } } @@ -1129,9 +1154,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1142,75 +1164,50 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { - "post": { - "operationId": "createKnowledgeDocumentUploadPartUrls", - "summary": "Create Document Upload Part URLs", - "description": "Issue short-lived signed PUT URLs for up to 100 multipart part numbers.", + }, + "patch": { + "operationId": "updateKnowledgeConnectorDocuments", + "summary": "Update Knowledge Connector Documents", + "description": "Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { "name": "id", "in": "path", "required": true, - "description": "Unique knowledge base identifier.", + "description": "Knowledge base that owns the connector.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge base identifier." + "description": "Knowledge base that owns the connector." } }, { - "name": "uploadId", + "name": "connectorId", "in": "path", "required": true, - "description": "Upload session identifier returned when the upload was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier returned when the upload was created." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the knowledge base.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "Connector selected for the operation.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Connector selected for the operation." } } ], "requestBody": { "required": true, - "description": "Multipart part numbers for which signed URLs should be created.", + "description": "Workspace, restore or exclude operation, and selected document identifiers.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadPartUrlsRequest" + "$ref": "#/components/schemas/UpdateKnowledgeConnectorDocumentsRequest" } } } }, "responses": { "200": { - "description": "Signed URLs for the requested upload parts.", + "description": "The selected connector documents were updated.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1225,7 +1222,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUploadPartUrlsResponse" + "$ref": "#/components/schemas/V2KnowledgeConnectorDocumentsUpdateResponse" } } } @@ -1242,9 +1239,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1260,62 +1254,26 @@ } } }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "/api/v2/knowledge/search": { "post": { - "operationId": "completeKnowledgeDocumentUpload", - "summary": "Complete Document Upload", - "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.", + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.", "tags": ["Knowledge Bases"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Unique knowledge base identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique knowledge base identifier." - } - }, - { - "name": "uploadId", - "in": "path", - "required": true, - "description": "Upload session identifier returned when the upload was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier returned when the upload was created." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the knowledge base.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the knowledge base." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "requestBody": { + "required": true, + "description": "Knowledge bases, query, result limit, retrieval mode, and optional tag filters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchKnowledgeRequest" + } } } - ], + }, "responses": { "200": { - "description": "The completed upload and queued document.", + "description": "Matching document chunks ordered by relevance.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1330,7 +1288,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" + "$ref": "#/components/schemas/V2KnowledgeSearchResponse" } } } @@ -1350,8 +1308,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1365,11 +1323,11 @@ } } }, - "/api/v2/knowledge/{id}/documents/{documentId}": { + "/api/v2/knowledge/{id}/tags": { "get": { - "operationId": "getKnowledgeDocument", - "summary": "Get Document", - "description": "Retrieve document detail, processing state, and connector provenance.", + "operationId": "listKnowledgeTags", + "summary": "List Tags", + "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1383,17 +1341,6 @@ "description": "Unique knowledge base identifier." } }, - { - "name": "documentId", - "in": "path", - "required": true, - "description": "Unique knowledge document identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique knowledge document identifier." - } - }, { "name": "workspaceId", "in": "query", @@ -1408,7 +1355,7 @@ ], "responses": { "200": { - "description": "The requested knowledge document.", + "description": "The knowledge base tag vocabulary.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1423,7 +1370,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDocumentResponse" + "$ref": "#/components/schemas/V2KnowledgeTagListResponse" } } } @@ -1450,11 +1397,13 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "updateKnowledgeDocument", - "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", + } + }, + "/api/v2/knowledge/{id}/documents": { + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{id}/tags`.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1469,31 +1418,187 @@ } }, { - "name": "documentId", + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the document filename.", + "schema": { + "description": "Case-insensitive substring match against the document filename.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter by whether documents are enabled for search.", + "schema": { + "default": "all", + "description": "Filter by whether documents are enabled for search.", + "type": "string", + "enum": ["all", "enabled", "disabled"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "tagFilters", + "in": "query", + "required": false, + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", + "schema": { + "description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.", + "examples": [ + "[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of knowledge documents.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeDocumentListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "bulkUpdateKnowledgeDocuments", + "summary": "Bulk Enable or Disable Documents", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{id}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", "in": "path", "required": true, - "description": "Unique knowledge document identifier.", + "description": "Unique knowledge base identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Unique knowledge document identifier." + "description": "Unique knowledge base identifier." } } ], "requestBody": { "required": true, - "description": "Filename, search state, tag slot values, or a processing retry.", + "description": "Operation and the documents it applies to.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateKnowledgeDocumentRequest" + "$ref": "#/components/schemas/BulkUpdateKnowledgeDocumentsRequest" } } } }, "responses": { "200": { - "description": "The updated document, or the requeue acknowledgement.", + "description": "The number and identifiers of the documents that changed.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1508,7 +1613,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpdateKnowledgeDocumentResponse" + "$ref": "#/components/schemas/V2BulkKnowledgeDocumentsResponse" } } } @@ -1539,10 +1644,10 @@ } } }, - "delete": { - "operationId": "deleteKnowledgeDocument", - "summary": "Delete Document", - "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1556,17 +1661,6 @@ "description": "Unique knowledge base identifier." } }, - { - "name": "documentId", - "in": "path", - "required": true, - "description": "Unique knowledge document identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique knowledge document identifier." - } - }, { "name": "workspaceId", "in": "query", @@ -1575,13 +1669,25 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } } ], + "requestBody": { + "required": true, + "description": "Multipart form containing the document file.", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UploadKnowledgeDocumentForm" + } + } + } + }, "responses": { - "200": { - "description": "Knowledge document deletion acknowledgement.", + "201": { + "description": "The accepted document queued for processing.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1596,7 +1702,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeDeleteResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentSummaryResponse" } } } @@ -1607,12 +1713,21 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1625,75 +1740,39 @@ } } }, - "/api/v2/knowledge/folders": { - "get": { - "operationId": "listKnowledgeFolders", - "summary": "List Folders", - "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "/api/v2/knowledge/{id}/documents/uploads": { + "post": { + "operationId": "createKnowledgeDocumentUpload", + "summary": "Create Document Upload", + "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "id", + "in": "path", "required": true, - "description": "Workspace whose folders should be listed.", + "description": "Unique knowledge base identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." - } - }, - { - "name": "parentPath", - "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path.", - "schema": { - "description": "Restrict results to direct children of this parent path.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", - "schema": { - "description": "Case-insensitive substring match against the folder name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] + "description": "Unique knowledge base identifier." } } ], + "requestBody": { + "required": true, + "description": "Document metadata used to authorize and initialize the upload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of knowledge-base folders.", + "201": { + "description": "The created upload session and transfer instructions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1708,7 +1787,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeFolderListResponse" + "$ref": "#/components/schemas/V2CreateKnowledgeDocumentUploadResponse" } } } @@ -1719,6 +1798,9 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -1728,6 +1810,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1738,26 +1823,64 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "post": { - "operationId": "createKnowledgeFolder", - "summary": "Create Folder", - "description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.", + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { + "delete": { + "operationId": "abortKnowledgeDocumentUpload", + "summary": "Abort Document Upload", + "description": "Abort an incomplete upload and discard provider-side multipart state.", "tags": ["Knowledge Bases"], - "requestBody": { - "required": true, - "description": "Workspace and canonical path for a new knowledge-base folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateKnowledgeFolderRequest" - } + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier returned when the upload was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier returned when the upload was created." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { - "201": { - "description": "The created knowledge-base folder.", + "200": { + "description": "The aborted upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1772,7 +1895,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeFolderResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" } } } @@ -1792,9 +1915,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1805,26 +1925,75 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "relocateKnowledgeFolder", - "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.", + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { + "post": { + "operationId": "createKnowledgeDocumentUploadPartUrls", + "summary": "Create Document Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for up to 100 multipart part numbers.", "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier returned when the upload was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier returned when the upload was created." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } + } + ], "requestBody": { "required": true, - "description": "Current and destination canonical paths for a knowledge-base folder.", + "description": "Multipart part numbers for which signed URLs should be created.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelocateKnowledgeFolderRequest" + "$ref": "#/components/schemas/CreateKnowledgeDocumentUploadPartUrlsRequest" } } } }, "responses": { "200": { - "description": "The relocated knowledge-base folder.", + "description": "Signed URLs for the requested upload parts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1839,7 +2008,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2KnowledgeFolderResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentUploadPartUrlsResponse" } } } @@ -1872,64 +2041,64 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteKnowledgeFolder", - "summary": "Delete Folder", - "description": "Delete a folder, optionally including nested folders and knowledge bases.", + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.", "tags": ["Knowledge Bases"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "id", + "in": "path", "required": true, - "description": "Workspace containing the folder.", + "description": "Unique knowledge base identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the folder." + "description": "Unique knowledge base identifier." } }, { - "name": "path", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Path of the folder to delete.", + "description": "Upload session identifier returned when the upload was created.", "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "type": "string", + "minLength": 1, + "description": "Upload session identifier returned when the upload was created." } }, { - "name": "recursive", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "required": true, + "description": "Workspace that owns the knowledge base.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" - } - } - ], + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } + } + ], "responses": { "200": { - "description": "Folder deletion acknowledgement and deleted item counts.", + "description": "The completed upload and queued document.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1944,7 +2113,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2DeleteKnowledgeFolderResponse" + "$ref": "#/components/schemas/V2KnowledgeDocumentUploadResponse" } } } @@ -1955,6 +2124,9 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -1964,9 +2136,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1978,319 +2147,2013 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/knowledge/{id}/documents/{documentId}": { + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve document detail, processing state, and connector provenance.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." } - } - } - }, - "Unauthorized": { - "description": "The API key is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "API key required" - } + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "USAGE_LIMIT_EXCEEDED", - "message": "Usage limit exceeded. Please upgrade your plan to continue." - } + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." } } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "The requested knowledge document.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeDocumentResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + "patch": { + "operationId": "updateKnowledgeDocument", + "summary": "Update Document", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{id}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Upload has already been completed" + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Filename, search state, tag slot values, or a processing retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeDocumentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated document, or the requeue acknowledgement.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpdateKnowledgeDocumentResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique knowledge base identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" + "type": "string", + "minLength": 1, + "description": "Unique knowledge base identifier." + } + }, + { + "name": "documentId", + "in": "path", + "required": true, + "description": "Unique knowledge document identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique knowledge document identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + } + } + ], + "responses": { + "200": { + "description": "Knowledge document deletion acknowledgement.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeDeleteResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/knowledge/folders": { + "get": { + "operationId": "listKnowledgeFolders", + "summary": "List Folders", + "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." + } + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Restrict results to direct children of this parent path.", + "schema": { + "description": "Restrict results to direct children of this parent path.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder name.", + "schema": { + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "A page of knowledge-base folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeFolderListResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createKnowledgeFolder", + "summary": "Create Folder", + "description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Knowledge Bases"], + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new knowledge-base folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeFolderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created knowledge-base folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "relocateKnowledgeFolder", + "summary": "Rename or Move Folder", + "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Knowledge Bases"], + "requestBody": { + "required": true, + "description": "Current and destination canonical paths for a knowledge-base folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateKnowledgeFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The relocated knowledge-base folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2KnowledgeFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeFolder", + "summary": "Delete Folder", + "description": "Delete a folder, optionally including nested folders and knowledge bases.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the folder.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + } + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Path of the folder to delete.", + "schema": { + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Folder deletion acknowledgement and deleted item counts.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteKnowledgeFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Upload has already been completed" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "V2KnowledgeBase": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "examples": ["Product Documentation"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Knowledge base description, or null when none is set.", + "examples": ["All product documentation and guides"] + }, + "tokenCount": { + "type": "number", + "description": "Total tokens across indexed documents.", + "examples": [48213] + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used to index documents.", + "examples": ["text-embedding-3-small"] + }, + "embeddingDimension": { + "type": "number", + "description": "Dimensionality of the embedding vectors.", + "examples": [1536] + }, + "chunkingConfig": { + "$ref": "#/components/schemas/V2KnowledgeChunkingConfig" + }, + "docCount": { + "description": "Number of documents in the knowledge base.", + "examples": [12], + "type": "number" + }, + "connectorTypes": { + "description": "External connector types that have synced documents into the knowledge base.", + "examples": [["notion", "google_drive"]], + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "format": "date-time", + "examples": ["2025-01-10T09:00:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "format": "date-time", + "examples": ["2025-06-18T16:45:00Z"] + }, + "ownerEmail": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Current email address of the knowledge base owner.", + "examples": ["owner@example.com"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Product"] + } + }, + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt", + "ownerEmail", + "folderPath" + ], + "additionalProperties": false, + "title": "Knowledge base", + "description": "A collection of documents indexed for vector and tag search." + }, + "V2KnowledgeChunkingConfig": { + "type": "object", + "properties": { + "maxSize": { + "type": "number", + "description": "Maximum chunk size in tokens.", + "examples": [1024] + }, + "minSize": { + "type": "number", + "description": "Minimum chunk size in characters.", + "examples": [100] + }, + "overlap": { + "type": "number", + "description": "Number of overlapping characters between adjacent chunks.", + "examples": [200] + }, + "strategy": { + "description": "Chunking strategy applied during document processing.", + "type": "string", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + }, + "strategyOptions": { + "description": "Strategy-specific tuning options.", + "type": "object", + "properties": { + "pattern": { + "description": "Regular expression used by the regex chunking strategy.", + "type": "string", + "maxLength": 500 + }, + "separators": { + "description": "Ordered separators used to split content into chunks.", + "type": "array", + "items": { + "type": "string" + } + }, + "recipe": { + "description": "Content-aware recipe used by the automatic chunking strategy.", + "type": "string", + "enum": ["plain", "markdown", "code"] + }, + "strictBoundaries": { + "description": "Whether regex matches must form strict chunk boundaries.", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": { + "description": "Additional forward-compatible chunking configuration property." + }, + "title": "Knowledge chunking configuration", + "description": "How documents in a knowledge base are split into chunks before embedding." + }, + "V2KnowledgeBaseListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeBase" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge base list response", + "description": "A cursor-paginated page of knowledge bases." + }, + "V2KnowledgeBaseResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeBase" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge base response", + "description": "A single knowledge base." + }, + "V2KnowledgeChunkingConfigInput": { + "type": "object", + "properties": { + "maxSize": { + "default": 1024, + "description": "Maximum chunk size in tokens.", + "examples": [1024], + "type": "number", + "minimum": 100, + "maximum": 4000 + }, + "minSize": { + "default": 100, + "description": "Minimum chunk size in characters.", + "examples": [100], + "type": "number", + "minimum": 1, + "maximum": 2000 + }, + "overlap": { + "default": 200, + "description": "Number of overlapping characters between adjacent chunks.", + "examples": [200], + "type": "number", + "minimum": 0, + "maximum": 500 + } + }, + "title": "Knowledge chunking configuration input", + "description": "Chunking configuration applied when processing documents." + }, + "CreateKnowledgeBaseRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the knowledge base." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "examples": ["Product Documentation"] + }, + "description": { + "description": "Optional knowledge base description.", + "examples": ["All product documentation and guides"], + "type": "string", + "maxLength": 10000 + }, + "chunkingConfig": { + "default": { + "maxSize": 1024, + "minSize": 100, + "overlap": 200 + }, + "description": "Chunking configuration; defaults are applied when omitted.", + "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + }, + "folderPath": { + "description": "Containing folder path; omission creates the knowledge base at the root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create knowledge base request", + "description": "Workspace, name, description, chunking configuration, and folder placement." + }, + "UpdateKnowledgeBaseRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "name": { + "description": "New knowledge base name.", + "examples": ["Updated Product Documentation"], + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "New knowledge base description.", + "examples": ["Refreshed product documentation and guides"], + "type": "string", + "maxLength": 10000 + }, + "chunkingConfig": { + "description": "New document chunking configuration.", + "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" + }, + "folderPath": { + "description": "New containing-folder path.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update knowledge base request", + "description": "Workspace scope and fields to update. At least one mutable field is required." + }, + "V2KnowledgeDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted resource.", + "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the resource was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Knowledge deletion data", + "description": "Acknowledgement for a deleted knowledge base or document." + }, + "V2KnowledgeDeleteResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge deletion response", + "description": "Deletion acknowledgement containing the removed resource identifier." + }, + "V2KnowledgeConnector": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique connector identifier." + }, + "knowledgeBaseId": { + "type": "string", + "minLength": 1, + "description": "Knowledge base synced by the connector." + }, + "connectorType": { + "type": "string", + "minLength": 1, + "description": "Registered external source type." + }, + "credentialId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." + }, + "sourceConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Connector-specific source configuration value." + }, + "description": "Connector-specific source selection and filtering configuration." + }, + "syncMode": { + "type": "string", + "description": "Synchronization mode used by the connector." + }, + "syncIntervalMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Scheduled synchronization interval in minutes; zero disables scheduled syncs." + }, + "status": { + "type": "string", + "enum": ["active", "paused", "syncing", "error", "disabled"], + "description": "Current connector state." + }, + "lastSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time of the most recent synchronization, or null before the first sync." + }, + "lastSyncError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Most recent synchronization error, or null when none is recorded." + }, + "lastSyncDocCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Documents observed by the most recent synchronization." + }, + "nextSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Next scheduled synchronization time, or null when not scheduled." + }, + "consecutiveFailures": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of consecutive synchronization failures." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was last updated." + } + }, + "required": [ + "id", + "knowledgeBaseId", + "connectorType", + "credentialId", + "sourceConfig", + "syncMode", + "syncIntervalMinutes", + "status", + "lastSyncAt", + "lastSyncError", + "lastSyncDocCount", + "nextSyncAt", + "consecutiveFailures", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Knowledge connector", + "description": "An external document source linked to a knowledge base, without secret material." + }, + "V2KnowledgeConnectorListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeConnector" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Knowledge connector list response", + "description": "A cursor-paginated page of connectors without secret material.", + "examples": [ + { + "data": [ + { + "id": "kc-9f8e7d6c", + "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncMode": "full", + "syncIntervalMinutes": 1440, + "status": "active", + "lastSyncAt": "2026-06-20T14:02:11.000Z", + "lastSyncError": null, + "lastSyncDocCount": 42, + "nextSyncAt": "2026-06-21T14:02:11.000Z", + "consecutiveFailures": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "V2KnowledgeConnectorResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnector" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector response", + "description": "A single connector without secret material.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncMode": "full", + "syncIntervalMinutes": 1440, + "status": "active", + "lastSyncAt": "2026-06-20T14:02:11.000Z", + "lastSyncError": null, + "lastSyncDocCount": 42, + "nextSyncAt": "2026-06-21T14:02:11.000Z", + "consecutiveFailures": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateKnowledgeConnectorRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "connectorType": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Registered connector type." + }, + "credentialId": { + "description": "OAuth credential identifier for connectors that require OAuth.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "apiKey": { + "description": "Write-only API key for connectors that use API-key authentication.", + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "sourceConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Connector-specific source configuration value." + }, + "description": "Connector-specific source selection and filtering configuration." + }, + "syncIntervalMinutes": { + "default": 1440, + "description": "Scheduled synchronization interval in minutes; zero disables scheduling.", + "type": "integer", + "minimum": 0, + "maximum": 525600 + } + }, + "required": ["workspaceId", "connectorType", "sourceConfig"], + "additionalProperties": false, + "title": "Create knowledge connector request", + "description": "Workspace, connector type, authentication reference, source configuration, and sync schedule.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncIntervalMinutes": 1440 + } + ] + }, + "V2KnowledgeConnectorSyncLog": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique synchronization log identifier." + }, + "connectorId": { + "type": "string", + "minLength": 1, + "description": "Connector that produced the log." + }, + "status": { + "type": "string", + "minLength": 1, + "description": "Synchronization outcome or current state." + }, + "startedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time synchronization started." + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time synchronization completed, or null while it is running." + }, + "docsAdded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents added." + }, + "docsUpdated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents updated." + }, + "docsDeleted": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents deleted." + }, + "docsUnchanged": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents unchanged." + }, + "docsFailed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Documents that failed to synchronize." + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Synchronization error, or null." + } + }, + "required": [ + "id", + "connectorId", + "status", + "startedAt", + "completedAt", + "docsAdded", + "docsUpdated", + "docsDeleted", + "docsUnchanged", + "docsFailed", + "errorMessage" + ], + "additionalProperties": false, + "title": "Knowledge connector sync log", + "description": "One synchronization attempt for a knowledge connector." + }, + "V2KnowledgeConnectorDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique connector identifier." + }, + "knowledgeBaseId": { + "type": "string", + "minLength": 1, + "description": "Knowledge base synced by the connector." + }, + "connectorType": { + "type": "string", + "minLength": 1, + "description": "Registered external source type." + }, + "credentialId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "OAuth credential identifier, or null for API-key and unauthenticated sources." + }, + "sourceConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Connector-specific source configuration value." + }, + "description": "Connector-specific source selection and filtering configuration." + }, + "syncMode": { + "type": "string", + "description": "Synchronization mode used by the connector." + }, + "syncIntervalMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Scheduled synchronization interval in minutes; zero disables scheduled syncs." + }, + "status": { + "type": "string", + "enum": ["active", "paused", "syncing", "error", "disabled"], + "description": "Current connector state." + }, + "lastSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time of the most recent synchronization, or null before the first sync." + }, + "lastSyncError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Most recent synchronization error, or null when none is recorded." + }, + "lastSyncDocCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Documents observed by the most recent synchronization." + }, + "nextSyncAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Next scheduled synchronization time, or null when not scheduled." + }, + "consecutiveFailures": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of consecutive synchronization failures." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the connector was last updated." + }, + "syncLogs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2KnowledgeConnectorSyncLog" + }, + "description": "The ten most recent synchronization attempts." + } + }, + "required": [ + "id", + "knowledgeBaseId", + "connectorType", + "credentialId", + "sourceConfig", + "syncMode", + "syncIntervalMinutes", + "status", + "lastSyncAt", + "lastSyncError", + "lastSyncDocCount", + "nextSyncAt", + "consecutiveFailures", + "createdAt", + "updatedAt", + "syncLogs" + ], + "additionalProperties": false, + "title": "Knowledge connector detail", + "description": "A knowledge connector and its recent synchronization history." + }, + "V2KnowledgeConnectorDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector detail response", + "description": "A connector and recent synchronization history without secret material.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "knowledgeBaseId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "connectorType": "notion", + "credentialId": "cred-4b3a2c1d", + "sourceConfig": { + "pageIds": ["page-123"] + }, + "syncMode": "full", + "syncIntervalMinutes": 1440, + "status": "active", + "lastSyncAt": "2026-06-20T14:02:11.000Z", + "lastSyncError": null, + "lastSyncDocCount": 42, + "nextSyncAt": "2026-06-21T14:02:11.000Z", + "consecutiveFailures": 0, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "syncLogs": [] + } } - } + ] }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "UpdateKnowledgeConnectorRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "sourceConfig": { + "description": "Replacement source selection and filtering configuration.", + "type": "object", + "propertyNames": { + "type": "string" }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "additionalProperties": { + "description": "Connector-specific source configuration value." } + }, + "syncIntervalMinutes": { + "description": "New scheduled synchronization interval in minutes.", + "type": "integer", + "minimum": 0, + "maximum": 525600 + }, + "status": { + "description": "New connector state.", + "type": "string", + "enum": ["active", "paused"] } - } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update knowledge connector request", + "description": "Workspace scope and at least one mutable connector field.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "status": "paused" + } + ] }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "V2KnowledgeConnectorDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Deleted connector identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the connector was deleted." + }, + "documentsDeleted": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Connector documents deleted." + }, + "documentsKept": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Connector documents retained." } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } + "required": ["id", "deleted", "documentsDeleted", "documentsKept"], + "additionalProperties": false, + "title": "Knowledge connector deletion data", + "description": "Connector deletion acknowledgement and affected document counts." + }, + "V2KnowledgeConnectorDeleteResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector delete response", + "description": "Deletion acknowledgement and affected document counts.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "deleted": true, + "documentsDeleted": 0, + "documentsKept": 42 } } - } + ] }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } - } + "V2KnowledgeConnectorSyncData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Connector queued for synchronization." + }, + "syncTriggered": { + "type": "boolean", + "const": true, + "description": "Whether synchronization was queued." } - } + }, + "required": ["id", "syncTriggered"], + "additionalProperties": false, + "title": "Knowledge connector sync data", + "description": "Acknowledgement that connector synchronization was queued." }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "V2KnowledgeConnectorSyncResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorSyncData" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } + "required": ["data"], + "additionalProperties": false, + "title": "Knowledge connector sync response", + "description": "Acknowledgement that synchronization was queued.", + "examples": [ + { + "data": { + "id": "kc-9f8e7d6c", + "syncTriggered": true } } - } - } - }, - "schemas": { - "V2Error": { + ] + }, + "SyncKnowledgeConnectorRequest": { "type": "object", "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." - } - }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the knowledge base." + }, + "rehydrate": { + "default": false, + "description": "Re-fetch and re-index every existing connector document.", + "type": "boolean" } }, - "required": ["error"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", + "title": "Sync knowledge connector request", + "description": "Workspace scope and optional full rehydration control.", "examples": [ { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "rehydrate": false } ] }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" - }, - "V2KnowledgeBase": { + "V2KnowledgeConnectorDocument": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique knowledge base identifier.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + "minLength": 1, + "description": "Unique document identifier." }, - "name": { + "filename": { "type": "string", - "description": "Human-readable knowledge base name.", - "examples": ["Product Documentation"] + "minLength": 1, + "description": "Document filename." }, - "description": { + "externalId": { "anyOf": [ { "type": "string" @@ -2299,150 +4162,59 @@ "type": "null" } ], - "description": "Knowledge base description, or null when none is set.", - "examples": ["All product documentation and guides"] - }, - "tokenCount": { - "type": "number", - "description": "Total tokens across indexed documents.", - "examples": [48213] - }, - "embeddingModel": { - "type": "string", - "description": "Embedding model used to index documents.", - "examples": ["text-embedding-3-small"] - }, - "embeddingDimension": { - "type": "number", - "description": "Dimensionality of the embedding vectors.", - "examples": [1536] - }, - "chunkingConfig": { - "$ref": "#/components/schemas/V2KnowledgeChunkingConfig" - }, - "docCount": { - "description": "Number of documents in the knowledge base.", - "examples": [12], - "type": "number" + "description": "Identifier assigned by the external source." }, - "connectorTypes": { - "description": "External connector types that have synced documents into the knowledge base.", - "examples": [["notion", "google_drive"]], - "type": "array", - "items": { - "type": "string" - } + "sourceUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Original external source URL." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the knowledge base was created.", - "format": "date-time", - "examples": ["2025-01-10T09:00:00Z"] + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for knowledge search." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the knowledge base was last modified.", - "format": "date-time", - "examples": ["2025-06-18T16:45:00Z"] + "userExcluded": { + "type": "boolean", + "description": "Whether a user explicitly excluded the document from connector sync results." }, - "ownerEmail": { + "createdAt": { "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Current email address of the knowledge base owner.", - "examples": ["owner@example.com"] + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the document was first synchronized." }, - "folderPath": { + "processingStatus": { "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Product"] + "description": "Current document processing state." } }, "required": [ "id", - "name", - "description", - "tokenCount", - "embeddingModel", - "embeddingDimension", - "chunkingConfig", + "filename", + "externalId", + "sourceUrl", + "enabled", + "userExcluded", "createdAt", - "updatedAt", - "ownerEmail", - "folderPath" + "processingStatus" ], "additionalProperties": false, - "title": "Knowledge base", - "description": "A collection of documents indexed for vector and tag search." - }, - "V2KnowledgeChunkingConfig": { - "type": "object", - "properties": { - "maxSize": { - "type": "number", - "description": "Maximum chunk size in tokens.", - "examples": [1024] - }, - "minSize": { - "type": "number", - "description": "Minimum chunk size in characters.", - "examples": [100] - }, - "overlap": { - "type": "number", - "description": "Number of overlapping characters between adjacent chunks.", - "examples": [200] - }, - "strategy": { - "description": "Chunking strategy applied during document processing.", - "type": "string", - "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] - }, - "strategyOptions": { - "description": "Strategy-specific tuning options.", - "type": "object", - "properties": { - "pattern": { - "description": "Regular expression used by the regex chunking strategy.", - "type": "string", - "maxLength": 500 - }, - "separators": { - "description": "Ordered separators used to split content into chunks.", - "type": "array", - "items": { - "type": "string" - } - }, - "recipe": { - "description": "Content-aware recipe used by the automatic chunking strategy.", - "type": "string", - "enum": ["plain", "markdown", "code"] - }, - "strictBoundaries": { - "description": "Whether regex matches must form strict chunk boundaries.", - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "required": ["maxSize", "minSize", "overlap"], - "additionalProperties": { - "description": "Additional forward-compatible chunking configuration property." - }, - "title": "Knowledge chunking configuration", - "description": "How documents in a knowledge base are split into chunks before embedding." + "title": "Knowledge connector document", + "description": "A knowledge document produced by an external connector." }, - "V2KnowledgeBaseListResponse": { + "V2KnowledgeConnectorDocumentListResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2KnowledgeBase" + "$ref": "#/components/schemas/V2KnowledgeConnectorDocument" }, "description": "Items in the current page." }, @@ -2460,95 +4232,76 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Knowledge base list response", - "description": "A cursor-paginated page of knowledge bases." - }, - "V2KnowledgeBaseResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeBase" + "title": "Knowledge connector document list response", + "description": "A cursor-paginated page of connector documents.", + "examples": [ + { + "data": [ + { + "id": "doc-8a7b6c5d", + "filename": "Product requirements", + "externalId": "page-123", + "sourceUrl": "https://www.notion.so/page-123", + "enabled": true, + "userExcluded": false, + "createdAt": "2026-06-01T09:15:00.000Z", + "processingStatus": "completed" + } + ], + "nextCursor": null } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Knowledge base response", - "description": "A single knowledge base." + ] }, - "V2KnowledgeChunkingConfigInput": { + "V2KnowledgeConnectorDocumentsUpdateData": { "type": "object", "properties": { - "maxSize": { - "default": 1024, - "description": "Maximum chunk size in tokens.", - "examples": [1024], - "type": "number", - "minimum": 100, - "maximum": 4000 - }, - "minSize": { - "default": 100, - "description": "Minimum chunk size in characters.", - "examples": [100], - "type": "number", - "minimum": 1, - "maximum": 2000 + "operation": { + "type": "string", + "enum": ["restore", "exclude"], + "description": "Operation that was applied." }, - "overlap": { - "default": 200, - "description": "Number of overlapping characters between adjacent chunks.", - "examples": [200], - "type": "number", + "updatedCount": { + "type": "integer", "minimum": 0, - "maximum": 500 + "maximum": 9007199254740991, + "description": "Documents changed." + }, + "documentIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of documents changed." } }, - "title": "Knowledge chunking configuration input", - "description": "Chunking configuration applied when processing documents." + "required": ["operation", "updatedCount", "documentIds"], + "additionalProperties": false, + "title": "Knowledge connector documents update data", + "description": "Outcome of restoring or excluding connector documents." }, - "CreateKnowledgeBaseRequest": { + "V2KnowledgeConnectorDocumentsUpdateResponse": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the knowledge base." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable knowledge base name.", - "examples": ["Product Documentation"] - }, - "description": { - "description": "Optional knowledge base description.", - "examples": ["All product documentation and guides"], - "type": "string", - "maxLength": 10000 - }, - "chunkingConfig": { - "default": { - "maxSize": 1024, - "minSize": 100, - "overlap": 200 - }, - "description": "Chunking configuration; defaults are applied when omitted.", - "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" - }, - "folderPath": { - "description": "Containing folder path; omission creates the knowledge base at the root.", - "$ref": "#/components/schemas/FolderPathInput" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2KnowledgeConnectorDocumentsUpdateData" } }, - "required": ["workspaceId", "name"], + "required": ["data"], "additionalProperties": false, - "title": "Create knowledge base request", - "description": "Workspace, name, description, chunking configuration, and folder placement." + "title": "Knowledge connector documents update response", + "description": "Operation result and identifiers actually changed.", + "examples": [ + { + "data": { + "operation": "exclude", + "updatedCount": 1, + "documentIds": ["doc-8a7b6c5d"] + } + } + ] }, - "UpdateKnowledgeBaseRequest": { + "UpdateKnowledgeConnectorDocumentsRequest": { "type": "object", "properties": { "workspaceId": { @@ -2557,64 +4310,34 @@ "maxLength": 128, "description": "Workspace that owns the knowledge base." }, - "name": { - "description": "New knowledge base name.", - "examples": ["Updated Product Documentation"], - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "description": { - "description": "New knowledge base description.", - "examples": ["Refreshed product documentation and guides"], - "type": "string", - "maxLength": 10000 - }, - "chunkingConfig": { - "description": "New document chunking configuration.", - "$ref": "#/components/schemas/V2KnowledgeChunkingConfigInput" - }, - "folderPath": { - "description": "New containing-folder path.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update knowledge base request", - "description": "Workspace scope and fields to update. At least one mutable field is required." - }, - "V2KnowledgeDeleteData": { - "type": "object", - "properties": { - "id": { + "operation": { "type": "string", - "description": "Identifier of the deleted resource.", - "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + "enum": ["restore", "exclude"], + "description": "Whether to restore or exclude the selected documents." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the resource was deleted." + "documentIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": "Connector document identifiers to update." } }, - "required": ["id", "deleted"], + "required": ["workspaceId", "operation", "documentIds"], "additionalProperties": false, - "title": "Knowledge deletion data", - "description": "Acknowledgement for a deleted knowledge base or document." - }, - "V2KnowledgeDeleteResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2KnowledgeDeleteData" + "title": "Update knowledge connector documents request", + "description": "Workspace, restore or exclude operation, and selected document identifiers.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "operation": "exclude", + "documentIds": ["doc-8a7b6c5d"] } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Knowledge deletion response", - "description": "Deletion acknowledgement containing the removed resource identifier." + ] }, "V2KnowledgeSearchResult": { "type": "object", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index f34b36a578e..a0f632ab0c7 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -52,6 +52,108 @@ } ], "paths": { + "/api/v2/workspaces": { + "get": { + "operationId": "listWorkspaces", + "summary": "List Workspaces", + "description": "List active workspaces available to the API key with opaque cursor pagination. A personal API key sees every accessible workspace that permits personal API keys; a workspace API key sees only its bound workspace.", + "tags": ["Workspaces"], + "parameters": [ + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Public metadata for workspaces available to the API key.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspacesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/workspaces/{workspaceId}": { "get": { "operationId": "getWorkspace", @@ -1009,18 +1111,298 @@ ], "requestBody": { "required": true, - "description": "Skill fields to change; at least one editable field is required.", + "description": "Skill fields to change; at least one editable field is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSkillRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated skill.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSkillResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteSkill", + "summary": "Delete Skill", + "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Skills"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the skill.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + } + } + ], + "responses": { + "200": { + "description": "The skill was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteSkillResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/skills/{id}/editors": { + "get": { + "operationId": "listSkillEditors", + "summary": "List Skill Editors", + "description": "List explicit skill editors and workspace administrators with opaque cursor pagination. Internal user and membership identifiers are never returned.", + "tags": ["Skills"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the skill.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "email", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["email", "name"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Users who can edit the skill.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSkillEditorsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "grantSkillEditor", + "summary": "Grant Skill Editor", + "description": "Grant editor access to a current workspace member by email. The caller must already be a skill editor or workspace administrator. Workspace administrators already have derived editor access and cannot receive an explicit grant. A retried existing grant returns 200; a newly created grant returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Skills"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and email of the member to grant.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSkillRequest" + "$ref": "#/components/schemas/GrantSkillEditorRequest" } } } }, "responses": { "200": { - "description": "The updated skill.", + "description": "The workspace member was already a skill editor.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1035,7 +1417,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSkillResponse" + "$ref": "#/components/schemas/GrantSkillEditorResponse" + } + } + } + }, + "201": { + "description": "The skill editor grant was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantSkillEditorResponse" } } } @@ -1052,9 +1455,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1070,9 +1470,9 @@ } }, "delete": { - "operationId": "deleteSkill", - "summary": "Delete Skill", - "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "revokeSkillEditor", + "summary": "Revoke Skill Editor", + "description": "Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Skills"], "parameters": [ { @@ -1097,11 +1497,23 @@ "maxLength": 128, "description": "Workspace that owns the skill." } + }, + { + "name": "email", + "in": "query", + "required": true, + "description": "Email address of a current workspace member.", + "schema": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of a current workspace member." + } } ], "responses": { "200": { - "description": "The skill was deleted.", + "description": "The explicit editor grant was revoked.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1116,7 +1528,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSkillResponse" + "$ref": "#/components/schemas/RevokeSkillEditorResponse" } } } @@ -1720,11 +2132,11 @@ "post": { "operationId": "createServiceAccountCredential", "summary": "Create Service-Account Credential", - "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Credentials"], "requestBody": { "required": true, - "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.", "content": { "application/json": { "schema": { @@ -2670,6 +3082,49 @@ "title": "Workspace", "description": "Public metadata for an accessible workspace." }, + "ListWorkspacesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Workspace" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List workspaces response", + "description": "Public metadata for workspaces available to the API key.", + "examples": [ + { + "data": [ + { + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, "GetWorkspaceResponse": { "type": "object", "properties": { @@ -3755,6 +4210,178 @@ } ] }, + "V2SkillEditor": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of the skill editor." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the skill editor." + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Profile image URL of the skill editor." + }, + "isWorkspaceAdmin": { + "type": "boolean", + "description": "Whether editor access is derived from workspace administration." + } + }, + "required": ["email", "name", "image", "isWorkspaceAdmin"], + "additionalProperties": false, + "title": "Skill editor", + "description": "Public identity fields for a user who can edit a skill." + }, + "ListSkillEditorsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2SkillEditor" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List skill editors response", + "description": "Public identity fields for users who can edit the skill.", + "examples": [ + { + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false + } + ], + "nextCursor": null + } + ] + }, + "GrantSkillEditorResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillEditor" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Grant skill editor response", + "description": "Public identity fields for the editor.", + "examples": [ + { + "data": { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false + } + } + ] + }, + "GrantSkillEditorRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of a current workspace member." + } + }, + "required": ["workspaceId", "email"], + "additionalProperties": false, + "title": "Grant skill editor request", + "description": "Workspace scope and email of the member to grant.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "email": "jane@example.com" + } + ] + }, + "V2SkillEditorDeleteData": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address whose explicit editor grant was revoked." + }, + "revoked": { + "type": "boolean", + "const": true, + "description": "Whether the explicit editor grant was revoked." + } + }, + "required": ["email", "revoked"], + "additionalProperties": false, + "title": "Revoke skill editor data", + "description": "Skill editor revocation acknowledgement." + }, + "RevokeSkillEditorResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillEditorDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revoke skill editor response", + "description": "Acknowledgement that the explicit editor grant was revoked.", + "examples": [ + { + "data": { + "email": "jane@example.com", + "revoked": true + } + } + ] + }, "V2CustomTool": { "type": "object", "properties": { @@ -4810,106 +5437,18 @@ "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "serviceAccountJson": { - "description": "Write-only Google service-account JSON key.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 65536 - }, - "apiToken": { - "description": "Write-only provider API token.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 - }, - "domain": { - "description": "Provider account domain.", - "type": "string", - "minLength": 1, - "maxLength": 2048 - }, - "signingSecret": { - "description": "Write-only webhook signing secret.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 - }, - "botToken": { - "description": "Write-only bot token.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 - }, - "clientId": { - "description": "OAuth client identifier.", + "credentials": { "type": "string", "minLength": 1, - "maxLength": 512 - }, - "clientSecret": { - "description": "Write-only OAuth client secret.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 1024 - }, - "certificateId": { - "description": "Provider certificate mapping identifier.", - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "orgId": { - "description": "Provider organization ID.", - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "dataCenter": { - "description": "Provider data center.", - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "authMethod": { - "description": "Provider authentication method.", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "privateKey": { - "description": "Write-only PEM private key.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 - }, - "username": { - "description": "Provider run-as username.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "maxLength": 131072, + "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", + "writeOnly": true } }, - "required": ["workspaceId", "type", "providerId"], + "required": ["workspaceId", "type", "providerId", "credentials"], "additionalProperties": false, "title": "Create service-account credential request", - "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "type": "service_account", - "providerId": "zoom-service-account", - "displayName": "Zoom automation", - "clientId": "YOUR_CLIENT_ID", - "clientSecret": "YOUR_CLIENT_SECRET", - "orgId": "YOUR_ACCOUNT_ID" - } - ] + "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery." }, "V2CredentialConnectionAuthorization": { "type": "object", diff --git a/apps/sim/app/api/skills/[id]/members/route.ts b/apps/sim/app/api/skills/[id]/members/route.ts index 27920ac9a70..3ed89fd4bc4 100644 --- a/apps/sim/app/api/skills/[id]/members/route.ts +++ b/apps/sim/app/api/skills/[id]/members/route.ts @@ -1,251 +1,87 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { skillMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { removeSkillMemberContract, upsertSkillMemberContract } from '@/lib/api/contracts/skills' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + listSkillMembersContract, + removeSkillMemberContract, + upsertSkillMemberContract, +} from '@/lib/api/contracts/skills' +import { + createInternalResourceConcealmentPolicy, + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' -import { getSkillActorContext, listSkillEditors } from '@/lib/skills/access' -import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('SkillMembersAPI') - -interface RouteContext { - params: Promise<{ id: string }> -} - -type SkillEditorGate = - | { ok: true; workspaceId: string } - | { ok: false; reason: 'not-found' | 'not-editor' } - -/** - * Resolves the skill and asserts the actor can edit it (explicit editor row or - * derived workspace admin). Skills the actor cannot reach at all (missing, - * builtin, no workspace, no workspace access) read as not-found; - * visible-but-not-editor reads as forbidden. - */ -async function requireSkillEditor(skillId: string, userId: string): Promise { - if (isBuiltinSkillId(skillId)) return { ok: false, reason: 'not-found' } - - const actor = await getSkillActorContext(skillId, userId) - if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { - return { ok: false, reason: 'not-found' } - } - if (!actor.canEdit) return { ok: false, reason: 'not-editor' } - - return { ok: true, workspaceId: actor.skill.workspaceId } -} - -function skillEditorGateResponse(reason: 'not-found' | 'not-editor'): NextResponse { - return reason === 'not-found' - ? NextResponse.json({ error: 'Not found' }, { status: 404 }) - : NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) -} - -export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: skillId } = await context.params - - if (isBuiltinSkillId(skillId)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const actor = await getSkillActorContext(skillId, session.user.id) - if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const editors = await listSkillEditors({ - id: actor.skill.id, - workspaceId: actor.skill.workspaceId, - }) - - return NextResponse.json({ editors }) - } catch (error) { - logger.error('Failed to fetch skill editors', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { skillOperations } from '@/lib/skills/application/operations' +import { + grantSkillEditorUseCase, + listSkillEditorsUseCase, + revokeSkillEditorUseCase, +} from '@/lib/skills/application/use-cases' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) +const errorPolicy = createInternalResourceConcealmentPolicy({ + base: internalOrchestrationErrorPolicy, + notFoundMessage: 'Not found', }) -export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: skillId } = await context.params - - const gate = await requireSkillEditor(skillId, session.user.id) - if (!gate.ok) { - logger.warn('Skill editor add denied', { - skillId, - actorId: session.user.id, - reason: gate.reason, - }) - return skillEditorGateResponse(gate.reason) - } - - const parsed = await parseRequest(upsertSkillMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId } = parsed.data.body - - const targetWorkspacePerm = await getUserEntityPermissions( - userId, - 'workspace', - gate.workspaceId - ) - if (targetWorkspacePerm === null) { - return NextResponse.json({ error: 'User is not a member of this workspace' }, { status: 400 }) - } - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins can always edit skills' }, - { status: 400 } - ) - } - - const [existing] = await db - .select({ id: skillMember.id }) - .from(skillMember) - .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId))) - .limit(1) - - if (existing) { - return NextResponse.json({ success: true }) - } - - const now = new Date() - // Conflict-safe against a concurrent add racing the unique (skillId, userId) index. - const [inserted] = await db - .insert(skillMember) - .values({ - id: generateId(), - skillId, - userId, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) - .returning({ id: skillMember.id }) - - // A concurrent request won the race and created the row. The editor exists, - // so this is still a success — but this request added nothing, and emitting - // the share event or audit entry here would record an add that never happened. - if (!inserted) { - return NextResponse.json({ success: true }) - } +export const GET = defineInternalJsonRoute({ + contract: listSkillMembersContract, + auth: internalSessionAuth, + operation: skillOperations.listEditors, + rateLimit, + errorPolicy, + mapInput: ({ params }) => ({ + skillId: params.id, + sortBy: 'email' as const, + sortOrder: 'asc' as const, + }), + useCase: listSkillEditorsUseCase, + present: ({ editors }) => ({ editors }), +}) +export const POST = defineInternalJsonRoute({ + contract: upsertSkillMemberContract, + auth: internalSessionAuth, + operation: skillOperations.grantEditor, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ + skillId: params.id, + target: { kind: 'user_id' as const, userId: body.userId }, + }), + useCase: grantSkillEditorUseCase, + onSuccess: ({ principal, input, result }) => { + if (!result.created || principal.kind !== 'session') return captureServerEvent( - session.user.id, + principal.userId, 'skill_shared', - { skill_id: skillId, workspace_id: gate.workspaceId }, - { groups: { workspace: gate.workspaceId } } + { skill_id: input.skillId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } ) - - recordAudit({ - workspaceId: gate.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SKILL_MEMBER_ADDED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: 'Added skill editor', - metadata: { targetUserId: userId }, - request, - }) - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (error) { - logger.error('Failed to add skill editor', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + }, + present: () => ({ success: true as const }), + statusForResult: ({ created }) => (created ? 201 : 200), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: skillId } = await context.params - - const gate = await requireSkillEditor(skillId, session.user.id) - if (!gate.ok) { - logger.warn('Skill editor removal denied', { - skillId, - actorId: session.user.id, - reason: gate.reason, - }) - return skillEditorGateResponse(gate.reason) - } - - const parsed = await parseRequest(removeSkillMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId: targetUserId } = parsed.data.query - - const targetWorkspacePerm = await getUserEntityPermissions( - targetUserId, - 'workspace', - gate.workspaceId - ) - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins can always edit skills' }, - { status: 400 } - ) - } - - // Hard delete — no deny markers and no last-editor guard: workspace admins - // always remain derived editors, so a skill can never be orphaned. - const removed = await db - .delete(skillMember) - .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, targetUserId))) - .returning({ id: skillMember.id }) - - if (removed.length === 0) { - return NextResponse.json({ error: 'Editor not found' }, { status: 404 }) - } - +export const DELETE = defineInternalJsonRoute({ + contract: removeSkillMemberContract, + auth: internalSessionAuth, + operation: skillOperations.revokeEditor, + rateLimit, + errorPolicy, + mapInput: ({ params, query }) => ({ + skillId: params.id, + target: { kind: 'user_id' as const, userId: query.userId }, + }), + useCase: revokeSkillEditorUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'session') return captureServerEvent( - session.user.id, + principal.userId, 'skill_unshared', - { skill_id: skillId, workspace_id: gate.workspaceId }, - { groups: { workspace: gate.workspaceId } } + { skill_id: input.skillId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } ) - - recordAudit({ - workspaceId: gate.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SKILL_MEMBER_REMOVED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: 'Removed skill editor', - metadata: { targetUserId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to remove skill editor', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + }, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index c06ce24edcf..777a0f81a78 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -253,10 +253,11 @@ describe('POST /api/v2/credentials', () => { type: 'service_account', providerId: 'zoom-service-account', displayName: 'Zoom account', - clientId: 'client-id', - clientSecret: 'client-secret', - certificateId: undefined, - orgId: 'account-id', + credentials: JSON.stringify({ + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }), }), }) const response = await POST(request) @@ -277,7 +278,6 @@ describe('POST /api/v2/credentials', () => { principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, input: { workspaceId: WORKSPACE_ID, - type: 'service_account', providerId: 'zoom-service-account', displayName: 'Zoom account', description: undefined, @@ -308,12 +308,78 @@ describe('POST /api/v2/credentials', () => { workspaceId: WORKSPACE_ID, type: 'service_account', providerId: 'made-up-service-account', - serviceAccountJson: '{}', + credentials: JSON.stringify({ serviceAccountJson: '{}' }), + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('rejects flattened provider fields before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it.each([ + ['malformed JSON', '{'], + ['a JSON array', '[]'], + ['an unsupported field', JSON.stringify({ extra: 'not-accepted' })], + ])('rejects credentials containing %s before the use case', async (_label, credentials) => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + credentials, + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('rejects missing provider fields inside credentials before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + credentials: JSON.stringify({ clientId: 'client-id', clientSecret: 'client-secret' }), }), }) ) expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + details: [{ path: ['credentials', 'orgId'] }], + }, + }) expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index d3f8c12b27c..3f925f6db9e 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -72,7 +72,14 @@ export const POST = defineV2JsonRoute({ operation: credentialOperations.createServiceAccount, rateLimit: v2RateLimits.publicApi, errorPolicy: credentialWorkspaceErrorPolicy, - mapInput: ({ body }) => body, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + providerId: body.providerId, + displayName: body.displayName, + description: body.description, + id: body.id, + ...body.credentials, + }), useCase: createServiceAccountCredentialUseCase, present: ({ credential, hasServiceAccountKey, role }) => ({ data: toV2Credential({ ...credential, hasServiceAccountKey, role }), diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts new file mode 100644 index 00000000000..35e06711f04 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route.ts @@ -0,0 +1,83 @@ +import { + v2ListKnowledgeConnectorDocumentsContract, + v2UpdateKnowledgeConnectorDocumentsContract, +} from '@/lib/api/contracts/v2/knowledge' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + listKnowledgeConnectorDocuments, + updateKnowledgeConnectorDocuments, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { toV2KnowledgeConnectorDocument } from '@/app/api/v2/knowledge/connector-utils' +import { decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +const CONNECTOR_DOCUMENT_SORT = 'userExcluded:asc,filename:asc' + +function connectorDocumentCursorScope( + knowledgeBaseId: string, + connectorId: string, + query: { workspaceId: string; includeExcluded: boolean } +) { + return cursorScopeKey( + cursorRoute(v2ListKnowledgeConnectorDocumentsContract, { + id: knowledgeBaseId, + connectorId, + }), + { + workspaceId: query.workspaceId, + includeExcluded: query.includeExcluded, + } + ) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeConnectorDocumentsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listConnectorDocuments, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: query.workspaceId, + includeExcluded: query.includeExcluded, + limit: query.limit, + offset: decodeOffsetCursor( + query.cursor, + CONNECTOR_DOCUMENT_SORT, + connectorDocumentCursorScope(params.id, params.connectorId, query) + ), + }), + useCase: listKnowledgeConnectorDocuments, + present: ({ documents, hasMore, offset, limit }, { params, query }) => ({ + data: documents.map(toV2KnowledgeConnectorDocument), + nextCursor: hasMore + ? encodeOffsetCursor( + CONNECTOR_DOCUMENT_SORT, + connectorDocumentCursorScope(params.id, params.connectorId, query), + offset + limit + ) + : null, + }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateKnowledgeConnectorDocumentsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.updateConnectorDocuments, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: body.workspaceId, + operation: body.operation, + documentIds: body.documentIds, + }), + useCase: updateKnowledgeConnectorDocuments, + present: ({ operation, count, documentIds }) => ({ + data: { operation, updatedCount: count, documentIds }, + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts new file mode 100644 index 00000000000..e4b1a835bae --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/route.ts @@ -0,0 +1,82 @@ +import { + v2DeleteKnowledgeConnectorContract, + v2GetKnowledgeConnectorContract, + v2UpdateKnowledgeConnectorContract, +} from '@/lib/api/contracts/v2/knowledge' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + deleteKnowledgeConnector, + readKnowledgeConnector, + updateKnowledgeConnector, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + toV2KnowledgeConnector, + toV2KnowledgeConnectorDetail, +} from '@/app/api/v2/knowledge/connector-utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = defineV2JsonRoute({ + contract: v2GetKnowledgeConnectorContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.readConnector, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readKnowledgeConnector, + present: ({ connector }) => ({ data: toV2KnowledgeConnectorDetail(connector) }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateKnowledgeConnectorContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.updateConnector, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: body.workspaceId, + updates: { + sourceConfig: body.sourceConfig, + syncIntervalMinutes: body.syncIntervalMinutes, + status: body.status, + }, + source: 'api' as const, + }), + useCase: updateKnowledgeConnector, + present: ({ connector }) => ({ data: toV2KnowledgeConnector(connector) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteKnowledgeConnectorContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteConnector, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: query.workspaceId, + deleteDocuments: query.deleteDocuments, + source: 'api' as const, + }), + useCase: deleteKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorRemoved, + present: ({ connectorId, documentsDeleted, documentsKept }) => ({ + data: { + id: connectorId, + deleted: true as const, + documentsDeleted, + documentsKept, + }, + }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts new file mode 100644 index 00000000000..3e8bf74cd66 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -0,0 +1,24 @@ +import { v2SyncKnowledgeConnectorContract } from '@/lib/api/contracts/v2/knowledge' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { syncKnowledgeConnector } from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2SyncKnowledgeConnectorContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.syncConnector, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + assertedWorkspaceId: body.workspaceId, + rehydrate: body.rehydrate, + source: 'api' as const, + }), + useCase: syncKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorSynced, + present: ({ connectorId }) => ({ data: { id: connectorId, syncTriggered: true as const } }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts new file mode 100644 index 00000000000..685a477429a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + sync: vi.fn(), + listDocuments: vi.fn(), + updateDocuments: vi.fn(), + connectorAdded: vi.fn(), + connectorRemoved: vi.fn(), + connectorSynced: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/knowledge/api/internal-route', () => ({ + internalKnowledgeAnalytics: { + connectorAdded: mocks.connectorAdded, + connectorRemoved: mocks.connectorRemoved, + connectorSynced: mocks.connectorSynced, + }, +})) +vi.mock('@/lib/knowledge/application/connectors', () => ({ + listKnowledgeConnectors: { + operation: { id: 'knowledge.connectors.list' }, + execute: mocks.list, + }, + createKnowledgeConnector: { + operation: { id: 'knowledge.connectors.create' }, + execute: mocks.create, + }, + readKnowledgeConnector: { + operation: { id: 'knowledge.connectors.read' }, + execute: mocks.read, + }, + updateKnowledgeConnector: { + operation: { id: 'knowledge.connectors.update' }, + execute: mocks.update, + }, + deleteKnowledgeConnector: { + operation: { id: 'knowledge.connectors.delete' }, + execute: mocks.remove, + }, + syncKnowledgeConnector: { + operation: { id: 'knowledge.connectors.sync' }, + execute: mocks.sync, + }, + listKnowledgeConnectorDocuments: { + operation: { id: 'knowledge.connectors.documents.list' }, + execute: mocks.listDocuments, + }, + updateKnowledgeConnectorDocuments: { + operation: { id: 'knowledge.connectors.documents.update' }, + execute: mocks.updateDocuments, + }, +})) + +import { + GET as listConnectorDocuments, + PATCH as updateConnectorDocuments, +} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route' +import { + DELETE as deleteConnector, + GET as getConnector, + PATCH as updateConnector, +} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/route' +import { POST as syncConnector } from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route' +import { + POST as createConnector, + GET as listConnectors, +} from '@/app/api/v2/knowledge/[id]/connectors/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const KNOWLEDGE_BASE_ID = 'knowledge-1' +const CONNECTOR_ID = 'connector-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const collectionContext = { params: Promise.resolve({ id: KNOWLEDGE_BASE_ID }) } +const connectorContext = { + params: Promise.resolve({ id: KNOWLEDGE_BASE_ID, connectorId: CONNECTOR_ID }), +} +const connector = { + id: CONNECTOR_ID, + knowledgeBaseId: KNOWLEDGE_BASE_ID, + connectorType: 'notion', + credentialId: 'credential-1', + sourceConfig: { pageIds: ['page-1'] }, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: null, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const document = { + id: 'document-1', + filename: 'Page one', + externalId: 'page-1', + sourceUrl: 'https://notion.so/page-1', + enabled: true, + userExcluded: false, + uploadedAt: new Date('2026-01-03T00:00:00Z'), + processingStatus: 'completed', +} + +function request(path: string, method = 'GET', body?: unknown) { + return new NextRequest(`http://localhost:3000${path}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('v2 knowledge connector routes', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ + connectors: [connector], + hasMore: false, + offset: 0, + limit: 50, + }) + mocks.create.mockResolvedValue({ connector, workspaceId: WORKSPACE_ID }) + mocks.read.mockResolvedValue({ connector: { ...connector, syncLogs: [] } }) + mocks.update.mockResolvedValue({ connector }) + mocks.remove.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE_ID, + workspaceId: WORKSPACE_ID, + connectorId: CONNECTOR_ID, + connectorType: connector.connectorType, + documentsDeleted: 0, + documentsKept: 1, + }) + mocks.sync.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE_ID, + workspaceId: WORKSPACE_ID, + connectorId: CONNECTOR_ID, + connectorType: connector.connectorType, + }) + mocks.listDocuments.mockResolvedValue({ + documents: [document], + counts: { active: 1, excluded: 0 }, + hasMore: false, + offset: 0, + limit: 50, + }) + mocks.updateDocuments.mockResolvedValue({ + operation: 'exclude', + count: 1, + documentIds: [document.id], + }) + }) + + it('lists connectors without secret fields', async () => { + const response = await listConnectors( + request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors?workspaceId=${WORKSPACE_ID}`), + collectionContext + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data[0]).toMatchObject({ id: CONNECTOR_ID, connectorType: 'notion' }) + expect(body.data[0]).not.toHaveProperty('encryptedApiKey') + expect(body.nextCursor).toBeNull() + }) + + it('creates a connector with principal-derived billing and write-only API keys', async () => { + const response = await createConnector( + request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors`, 'POST', { + workspaceId: WORKSPACE_ID, + connectorType: 'notion', + apiKey: 'secret-value', + sourceConfig: { pageIds: ['page-1'] }, + }), + collectionContext + ) + + expect(response.status).toBe(201) + expect((await response.json()).data).not.toHaveProperty('apiKey') + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.not.objectContaining({ resolveBillingAttribution: expect.anything() }), + }) + ) + expect(mocks.connectorAdded).toHaveBeenCalledOnce() + }) + + it('gets, updates, and deletes one connector', async () => { + const getResponse = await getConnector( + request( + `/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}?workspaceId=${WORKSPACE_ID}` + ), + connectorContext + ) + expect(getResponse.status).toBe(200) + expect((await getResponse.json()).data.syncLogs).toEqual([]) + + const patchResponse = await updateConnector( + request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}`, 'PATCH', { + workspaceId: WORKSPACE_ID, + status: 'paused', + }), + connectorContext + ) + expect(patchResponse.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + assertedWorkspaceId: WORKSPACE_ID, + updates: expect.objectContaining({ status: 'paused' }), + }), + }) + ) + + const deleteResponse = await deleteConnector( + request( + `/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}?workspaceId=${WORKSPACE_ID}&deleteDocuments=false`, + 'DELETE' + ), + connectorContext + ) + expect(deleteResponse.status).toBe(200) + expect(await deleteResponse.json()).toEqual({ + data: { + id: CONNECTOR_ID, + deleted: true, + documentsDeleted: 0, + documentsKept: 1, + }, + }) + expect(mocks.connectorRemoved).toHaveBeenCalledOnce() + }) + + it('queues connector synchronization without an adapter billing resolver', async () => { + const response = await syncConnector( + request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/sync`, 'POST', { + workspaceId: WORKSPACE_ID, + rehydrate: true, + }), + connectorContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: CONNECTOR_ID, syncTriggered: true }, + }) + expect(mocks.sync).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.not.objectContaining({ resolveBillingAttribution: expect.anything() }), + }) + ) + expect(mocks.connectorSynced).toHaveBeenCalledOnce() + }) + + it('lists and updates connector documents', async () => { + const getResponse = await listConnectorDocuments( + request( + `/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/documents?workspaceId=${WORKSPACE_ID}` + ), + connectorContext + ) + expect(getResponse.status).toBe(200) + expect((await getResponse.json()).data[0]).toEqual({ + id: document.id, + filename: document.filename, + externalId: document.externalId, + sourceUrl: document.sourceUrl, + enabled: true, + userExcluded: false, + createdAt: '2026-01-03T00:00:00.000Z', + processingStatus: 'completed', + }) + + const patchResponse = await updateConnectorDocuments( + request( + `/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/documents`, + 'PATCH', + { + workspaceId: WORKSPACE_ID, + operation: 'exclude', + documentIds: [document.id], + } + ), + connectorContext + ) + expect(patchResponse.status).toBe(200) + expect(await patchResponse.json()).toEqual({ + data: { operation: 'exclude', updatedCount: 1, documentIds: [document.id] }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts new file mode 100644 index 00000000000..43fb3e5aa70 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/connectors/route.ts @@ -0,0 +1,76 @@ +import { + v2CreateKnowledgeConnectorContract, + v2ListKnowledgeConnectorsContract, +} from '@/lib/api/contracts/v2/knowledge' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + createKnowledgeConnector, + listKnowledgeConnectors, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { toV2KnowledgeConnector } from '@/app/api/v2/knowledge/connector-utils' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +function connectorCursorScope(knowledgeBaseId: string, workspaceId: string) { + return cursorScopeKey(cursorRoute(v2ListKnowledgeConnectorsContract, { id: knowledgeBaseId }), { + workspaceId, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListKnowledgeConnectorsContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listConnectors, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + connectorCursorScope(params.id, query.workspaceId) + ), + }), + useCase: listKnowledgeConnectors, + present: ({ connectors, hasMore, offset, limit }, { params, query }) => ({ + data: connectors.map(toV2KnowledgeConnector), + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + connectorCursorScope(params.id, query.workspaceId), + offset + limit + ) + : null, + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateKnowledgeConnectorContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.createConnector, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: body.workspaceId, + connectorType: body.connectorType, + credentialId: body.credentialId, + apiKey: body.apiKey, + sourceConfig: body.sourceConfig, + syncIntervalMinutes: body.syncIntervalMinutes, + source: 'api' as const, + }), + useCase: createKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorAdded, + present: ({ connector }) => ({ data: toV2KnowledgeConnector(connector) }), +}) diff --git a/apps/sim/app/api/v2/knowledge/connector-utils.ts b/apps/sim/app/api/v2/knowledge/connector-utils.ts new file mode 100644 index 00000000000..b8330c1bdc7 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/connector-utils.ts @@ -0,0 +1,112 @@ +import { + type V2KnowledgeConnector, + type V2KnowledgeConnectorDetail, + type V2KnowledgeConnectorDocument, + v2KnowledgeConnectorDetailSchema, + v2KnowledgeConnectorDocumentSchema, + v2KnowledgeConnectorSchema, + v2KnowledgeConnectorSyncLogSchema, +} from '@/lib/api/contracts/v2/knowledge' + +function serializeDate(value: Date | string): string { + return value instanceof Date ? value.toISOString() : value +} + +function serializeNullableDate(value: Date | string | null): string | null { + return value === null ? null : serializeDate(value) +} + +interface KnowledgeConnectorProjection { + id: string + knowledgeBaseId: string + connectorType: string + credentialId: string | null + sourceConfig: unknown + syncMode: string | null + syncIntervalMinutes: number + status: string + lastSyncAt: Date | string | null + lastSyncError: string | null + lastSyncDocCount: number | null + nextSyncAt: Date | string | null + consecutiveFailures: number + createdAt: Date | string + updatedAt: Date | string +} + +interface KnowledgeConnectorSyncLogProjection { + id: string + connectorId: string + status: string + startedAt: Date | string + completedAt: Date | string | null + docsAdded: number + docsUpdated: number + docsDeleted: number + docsUnchanged: number + docsFailed: number + errorMessage: string | null +} + +interface KnowledgeConnectorDocumentProjection { + id: string + filename: string + externalId: string | null + sourceUrl: string | null + enabled: boolean + userExcluded: boolean + uploadedAt: Date | string + processingStatus: string +} + +export function toV2KnowledgeConnector( + connector: KnowledgeConnectorProjection +): V2KnowledgeConnector { + return v2KnowledgeConnectorSchema.parse({ + id: connector.id, + knowledgeBaseId: connector.knowledgeBaseId, + connectorType: connector.connectorType, + credentialId: connector.credentialId, + sourceConfig: connector.sourceConfig, + syncMode: connector.syncMode, + syncIntervalMinutes: connector.syncIntervalMinutes, + status: connector.status, + lastSyncAt: serializeNullableDate(connector.lastSyncAt), + lastSyncError: connector.lastSyncError, + lastSyncDocCount: connector.lastSyncDocCount, + nextSyncAt: serializeNullableDate(connector.nextSyncAt), + consecutiveFailures: connector.consecutiveFailures, + createdAt: serializeDate(connector.createdAt), + updatedAt: serializeDate(connector.updatedAt), + }) +} + +export function toV2KnowledgeConnectorDetail( + connector: KnowledgeConnectorProjection & { syncLogs: KnowledgeConnectorSyncLogProjection[] } +): V2KnowledgeConnectorDetail { + return v2KnowledgeConnectorDetailSchema.parse({ + ...toV2KnowledgeConnector(connector), + syncLogs: connector.syncLogs.map((log) => + v2KnowledgeConnectorSyncLogSchema.parse({ + ...log, + startedAt: serializeDate(log.startedAt), + completedAt: serializeNullableDate(log.completedAt), + }) + ), + }) +} + +export function toV2KnowledgeConnectorDocument( + document: KnowledgeConnectorDocumentProjection +): V2KnowledgeConnectorDocument { + return v2KnowledgeConnectorDocumentSchema.parse({ + id: document.id, + filename: document.filename, + externalId: document.externalId, + sourceUrl: document.sourceUrl, + enabled: document.enabled, + userExcluded: document.userExcluded, + createdAt: serializeDate(document.uploadedAt), + processingStatus: document.processingStatus, + }) +} diff --git a/apps/sim/app/api/v2/skills/[id]/editors/route.test.ts b/apps/sim/app/api/v2/skills/[id]/editors/route.test.ts new file mode 100644 index 00000000000..1f975c06269 --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/editors/route.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + grant: vi.fn(), + revoke: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/skills/application/use-cases', () => ({ + listSkillEditorsUseCase: { + operation: { id: 'skills.editors.list' }, + execute: mocks.list, + }, + grantSkillEditorUseCase: { + operation: { id: 'skills.editors.grant' }, + execute: mocks.grant, + }, + revokeSkillEditorUseCase: { + operation: { id: 'skills.editors.revoke' }, + execute: mocks.revoke, + }, +})) + +import { DELETE, GET, POST } from '@/app/api/v2/skills/[id]/editors/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const SKILL_ID = 'skill-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const context = { params: Promise.resolve({ id: SKILL_ID }) } +const editor = { + id: 'membership-1', + userId: 'user-2', + userName: 'Ada', + userEmail: 'ada@example.com', + userImage: null, + isWorkspaceAdmin: false, +} + +function request(method: 'GET' | 'POST' | 'DELETE', body?: unknown) { + const query = + method === 'GET' + ? `?workspaceId=${WORKSPACE_ID}` + : method === 'DELETE' + ? `?workspaceId=${WORKSPACE_ID}&email=${encodeURIComponent(editor.userEmail)}` + : '' + return new NextRequest(`http://localhost:3000/api/v2/skills/${SKILL_ID}/editors${query}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/skills/[id]/editors', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(AUTH) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ + editors: [editor], + hasMore: false, + offset: 0, + limit: 50, + }) + mocks.grant.mockResolvedValue({ editor, created: true, workspaceId: WORKSPACE_ID }) + mocks.revoke.mockResolvedValue({ editor, workspaceId: WORKSPACE_ID }) + }) + + it('lists public editor identity fields without internal IDs', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + email: editor.userEmail, + name: editor.userName, + image: null, + isWorkspaceAdmin: false, + }, + ], + nextCursor: null, + }) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + skillId: SKILL_ID, + workspaceId: WORKSPACE_ID, + sortBy: 'email', + sortOrder: 'asc', + limit: 50, + offset: 0, + }), + }) + ) + }) + + it('allows a workspace key to list the editor roster', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + } + v2RouteMocks.authenticate.mockResolvedValueOnce({ + ...AUTH, + principal, + keyType: 'workspace' as const, + }) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith(expect.objectContaining({ principal })) + }) + + it('mints a cursor for the next editor page', async () => { + mocks.list.mockResolvedValueOnce({ + editors: [editor], + hasMore: true, + offset: 0, + limit: 1, + }) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(typeof (await response.json()).nextCursor).toBe('string') + }) + + it('creates an editor grant by email and returns 201', async () => { + const response = await POST( + request('POST', { workspaceId: WORKSPACE_ID, email: editor.userEmail }), + context + ) + + expect(response.status).toBe(201) + expect(mocks.grant).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + skillId: SKILL_ID, + workspaceId: WORKSPACE_ID, + target: { kind: 'email', email: editor.userEmail }, + }, + }) + ) + expect(mocks.capture).toHaveBeenCalledOnce() + }) + + it('returns 200 for an idempotent existing editor grant', async () => { + mocks.grant.mockResolvedValueOnce({ editor, created: false, workspaceId: WORKSPACE_ID }) + + const response = await POST( + request('POST', { workspaceId: WORKSPACE_ID, email: editor.userEmail }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.capture).not.toHaveBeenCalled() + }) + + it('revokes an explicit editor grant by email', async () => { + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { email: editor.userEmail, revoked: true }, + }) + expect(mocks.revoke).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + skillId: SKILL_ID, + workspaceId: WORKSPACE_ID, + target: { kind: 'email', email: editor.userEmail }, + }, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/[id]/editors/route.ts b/apps/sim/app/api/v2/skills/[id]/editors/route.ts new file mode 100644 index 00000000000..3b4a4791c2f --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/editors/route.ts @@ -0,0 +1,128 @@ +import { + type V2SkillEditor, + v2GrantSkillEditorContract, + v2ListSkillEditorsContract, + v2RevokeSkillEditorContract, +} from '@/lib/api/contracts/v2/skills' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import type { SkillEditor } from '@/lib/skills/access' +import { skillOperations } from '@/lib/skills/application/operations' +import { + grantSkillEditorUseCase, + listSkillEditorsUseCase, + revokeSkillEditorUseCase, +} from '@/lib/skills/application/use-cases' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const skillResourceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Skill not found', +}) + +function toV2SkillEditor(editor: SkillEditor): V2SkillEditor { + if (!editor.userEmail) throw new Error('Skill editor is missing an email address') + return { + email: editor.userEmail, + name: editor.userName, + image: editor.userImage ?? null, + isWorkspaceAdmin: editor.isWorkspaceAdmin, + } +} + +function skillEditorCursorScope(skillId: string, query: { workspaceId: string }) { + return cursorScopeKey(cursorRoute(v2ListSkillEditorsContract, { id: skillId }), { + workspaceId: query.workspaceId, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListSkillEditorsContract, + operation: skillOperations.listEditors, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: skillResourceErrorPolicy, + mapInput: ({ params, query }) => ({ + skillId: params.id, + workspaceId: query.workspaceId, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + skillEditorCursorScope(params.id, query) + ), + }), + useCase: listSkillEditorsUseCase, + present: ({ editors, hasMore, offset, limit }, { params, query }) => ({ + data: editors.map(toV2SkillEditor), + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + skillEditorCursorScope(params.id, query), + offset + limit + ) + : null, + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2GrantSkillEditorContract, + operation: skillOperations.grantEditor, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: skillResourceErrorPolicy, + mapInput: ({ params, body }) => ({ + skillId: params.id, + workspaceId: body.workspaceId, + target: { kind: 'email' as const, email: body.email }, + }), + useCase: grantSkillEditorUseCase, + onSuccess: ({ principal, input, result }) => { + if (!result.created || principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'skill_shared', + { skill_id: input.skillId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + present: ({ editor }) => ({ data: toV2SkillEditor(editor) }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2RevokeSkillEditorContract, + operation: skillOperations.revokeEditor, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: skillResourceErrorPolicy, + mapInput: ({ params, query }) => ({ + skillId: params.id, + workspaceId: query.workspaceId, + target: { kind: 'email' as const, email: query.email }, + }), + useCase: revokeSkillEditorUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'skill_unshared', + { skill_id: input.skillId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + present: ({ editor }) => { + if (!editor.userEmail) throw new Error('Skill editor is missing an email address') + return { data: { email: editor.userEmail, revoked: true as const } } + }, +}) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index e57d7679e56..39cfe5b28e4 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getWorkspace: vi.fn(), + listWorkspaces: vi.fn(), listMembers: vi.fn(), })) @@ -35,10 +36,18 @@ vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ }, })) +vi.mock('@/lib/workspaces/application/list-public-workspaces', () => ({ + listPublicWorkspaces: { + operation: { id: 'workspaces.list_public' }, + execute: mocks.listWorkspaces, + }, +})) + import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' +import { GET as listWorkspaces } from '@/app/api/v2/workspaces/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const auth = { @@ -115,6 +124,53 @@ describe('v2 workspace routes', () => { nextEmail: 'ada@example.com', }, }) + mocks.listWorkspaces.mockResolvedValue({ + workspaces: [ + { + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + memberCount: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + }, + ], + hasMore: false, + offset: 0, + limit: 50, + }) + }) + + it('lists the workspaces available to the API key', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/workspaces') + const response = await listWorkspaces(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + memberCount: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.listWorkspaces).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + sortBy: 'createdAt', + sortOrder: 'desc', + limit: 50, + offset: 0, + }, + request, + }) }) it('projects public workspace metadata without governance identities', async () => { diff --git a/apps/sim/app/api/v2/workspaces/route.ts b/apps/sim/app/api/v2/workspaces/route.ts new file mode 100644 index 00000000000..b849dfb6f5f --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/route.ts @@ -0,0 +1,49 @@ +import { v2ListWorkspacesContract } from '@/lib/api/contracts/v2/workspaces' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listPublicWorkspaces } from '@/lib/workspaces/application/list-public-workspaces' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const cursorScope = cursorScopeKey(cursorRoute(v2ListWorkspacesContract), {}) + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspacesContract, + auth: v2ApiKeyAuth, + operation: workspaceOperations.listPublic, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + cursorScope + ), + }), + useCase: listPublicWorkspaces, + present: ({ workspaces, hasMore, offset, limit }, { query }) => ({ + data: workspaces.map((workspace) => ({ + ...workspace, + createdAt: workspace.createdAt.toISOString(), + updatedAt: workspace.updatedAt.toISOString(), + })), + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + cursorScope, + offset + limit + ) + : null, + }), +}) diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 3b29193d87b..1a609d95eb3 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -125,19 +125,25 @@ export const deleteSkillContract = defineRouteContract({ }, }) -export const skillIdParamsSchema = z.object({ - id: z.string().min(1, 'Skill id is required'), -}) +export const skillIdParamsSchema = z + .object({ + id: z.string().min(1, 'Skill id is required'), + }) + .strict() -export const upsertSkillMemberBodySchema = z.object({ - userId: z.string().min(1, 'User id is required'), -}) +export const upsertSkillMemberBodySchema = z + .object({ + userId: z.string().min(1, 'User id is required'), + }) + .strict() export type UpsertSkillMemberBody = z.input -export const removeSkillMemberQuerySchema = z.object({ - userId: z.string().min(1, 'User id is required'), -}) +export const removeSkillMemberQuerySchema = z + .object({ + userId: z.string().min(1, 'User id is required'), + }) + .strict() export const listSkillMembersContract = defineRouteContract({ method: 'GET', @@ -158,6 +164,7 @@ export const upsertSkillMemberContract = defineRouteContract({ body: upsertSkillMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), }), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index d52ec521993..14e0b57481f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -51,11 +51,14 @@ const PAGED_LISTS = [ 'GET /api/v2/custom-tools', 'GET /api/v2/files', 'GET /api/v2/knowledge', + 'GET /api/v2/knowledge/[id]/connectors', + 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents', 'GET /api/v2/knowledge/[id]/documents', 'GET /api/v2/logs', 'GET /api/v2/mcp-servers', 'GET /api/v2/secrets', 'GET /api/v2/skills', + 'GET /api/v2/skills/[id]/editors', 'GET /api/v2/tables', 'GET /api/v2/tables/[tableId]/rows', 'POST /api/v2/tables/[tableId]/query', @@ -63,6 +66,7 @@ const PAGED_LISTS = [ 'GET /api/v2/workflows/[id]/runs', 'GET /api/v2/workflows/[id]/versions', 'GET /api/v2/workspaces/[workspaceId]/members', + 'GET /api/v2/workspaces', ] as const /** @@ -143,6 +147,11 @@ const CURSOR_BINDINGS: Record = { 'recursive', ], 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[id]/connectors': ['workspaceId', 'sortBy', 'sortOrder'], + 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': [ + 'workspaceId', + 'includeExcluded', + ], 'GET /api/v2/knowledge/[id]/documents': [ 'workspaceId', 'enabledFilter', @@ -170,6 +179,7 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/skills/[id]/editors': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/tables/[tableId]/rows': [], 'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'], @@ -184,6 +194,7 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/workflows/[id]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'], 'GET /api/v2/workflows/[id]/versions': [], 'GET /api/v2/workspaces/[workspaceId]/members': [], + 'GET /api/v2/workspaces': ['sortBy', 'sortOrder'], } /** @@ -207,7 +218,10 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/knowledge/[id]/connectors': ['id'], + 'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': ['id', 'connectorId'], 'GET /api/v2/knowledge/[id]/documents': ['id'], + 'GET /api/v2/skills/[id]/editors': ['id'], 'GET /api/v2/tables/[tableId]/rows': ['tableId'], 'POST /api/v2/tables/[tableId]/query': ['tableId'], 'GET /api/v2/workflows/[id]/runs': ['id'], diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 3f4c3b05bb0..851165c3920 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,6 +1,11 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + missingFieldError, + noInputSchema, + nonEmptyIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -285,74 +290,109 @@ export const v2CreateCredentialConnectionContract = defineRouteContract({ }, }) -const v2ServiceAccountSecretFieldsShape = { - serviceAccountJson: z - .string() - .min(1) - .max(65_536) - .optional() - .describe('Write-only Google service-account JSON key.') - .meta({ writeOnly: true }), - apiToken: z - .string() - .trim() - .min(1) - .max(8192) - .optional() - .describe('Write-only provider API token.') - .meta({ writeOnly: true }), - domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), - signingSecret: z - .string() - .trim() - .min(1) - .max(8192) - .optional() - .describe('Write-only webhook signing secret.') - .meta({ writeOnly: true }), - botToken: z - .string() - .trim() - .min(1) - .max(8192) - .optional() - .describe('Write-only bot token.') - .meta({ writeOnly: true }), - clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), - clientSecret: z - .string() - .trim() - .min(1) - .max(1024) - .optional() - .describe('Write-only OAuth client secret.') - .meta({ writeOnly: true }), - certificateId: z - .string() - .trim() - .min(1) - .max(512) - .optional() - .describe('Provider certificate mapping identifier.'), - orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), - dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), - authMethod: z - .string() - .trim() - .min(1) - .max(64) - .optional() - .describe('Provider authentication method.'), - privateKey: z - .string() - .trim() - .min(1) - .max(8192) - .optional() - .describe('Write-only PEM private key.') - .meta({ writeOnly: true }), - username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), -} as const +const v2ServiceAccountCredentialFieldsSchema = z + .object({ + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + }) + .strict() + +type V2ServiceAccountCredentialFields = z.output + +const v2ServiceAccountCredentialsJsonSchema = z + .string({ error: missingFieldError('credentials is required') }) + .min(1, 'credentials cannot be empty') + .max(131_072, 'credentials must be at most 131072 characters') + .describe( + 'Write-only JSON object string containing the fields declared by credential-provider discovery.' + ) + .meta({ writeOnly: true }) + .transform((value, ctx): V2ServiceAccountCredentialFields => { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + ctx.addIssue({ code: 'custom', message: 'credentials must be valid JSON' }) + return z.NEVER + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + ctx.addIssue({ code: 'custom', message: 'credentials must be a JSON object' }) + return z.NEVER + } + + const result = v2ServiceAccountCredentialFieldsSchema.safeParse(parsed) + if (result.success) return result.data + + for (const issue of result.error.issues) { + ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }) + } + return z.NEVER + }) export const v2CreateServiceAccountCredentialBodySchema = z .object({ @@ -382,7 +422,7 @@ export const v2CreateServiceAccountCredentialBodySchema = z .uuid('id must be a valid UUID') .optional() .describe('Required only when provider discovery requests a client-generated ID.'), - ...v2ServiceAccountSecretFieldsShape, + credentials: v2ServiceAccountCredentialsJsonSchema, }) .strict() .superRefine((body, ctx) => { @@ -402,10 +442,10 @@ export const v2CreateServiceAccountCredentialBodySchema = z }) } for (const field of getServiceAccountRequiredFields(body.providerId)) { - if (!body[field]) { + if (!body.credentials[field]) { ctx.addIssue({ code: 'custom', - path: [field], + path: ['credentials', field], message: `${field} is required for ${body.providerId} credentials`, }) } diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index b30e5900a1a..a45f021cb03 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -3,10 +3,15 @@ import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' import { knowledgeBaseParamsSchema, + knowledgeConnectorParamsSchema, knowledgeDocumentParamsSchema, nullableWireDateSchema, } from '@/lib/api/contracts/knowledge/shared' -import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + noInputSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { KNOWLEDGE_TAG_FILTER_OPERATORS_BY_FIELD_TYPE, @@ -41,7 +46,10 @@ import { v2UploadTokenHeadersSchema, v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' -import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS, +} from '@/lib/knowledge/constants' import { DEFAULT_RERANKER_MODEL, rerankerModelSchema, @@ -1463,3 +1471,378 @@ export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ schema: v2DataResponse(v2KnowledgeDeleteDataSchema), }, }) + +export const v2KnowledgeConnectorSchema = z + .object({ + id: z.string().min(1).describe('Unique connector identifier.'), + knowledgeBaseId: z.string().min(1).describe('Knowledge base synced by the connector.'), + connectorType: z.string().min(1).describe('Registered external source type.'), + credentialId: z + .string() + .nullable() + .describe('OAuth credential identifier, or null for API-key and unauthenticated sources.'), + sourceConfig: z + .record(z.string(), z.unknown().describe('Connector-specific source configuration value.')) + .describe('Connector-specific source selection and filtering configuration.'), + syncMode: z.string().describe('Synchronization mode used by the connector.'), + syncIntervalMinutes: z + .number() + .int() + .nonnegative() + .describe('Scheduled synchronization interval in minutes; zero disables scheduled syncs.'), + status: z + .enum(['active', 'paused', 'syncing', 'error', 'disabled']) + .describe('Current connector state.'), + lastSyncAt: v2TimestampSchema + .nullable() + .describe('Time of the most recent synchronization, or null before the first sync.'), + lastSyncError: z + .string() + .nullable() + .describe('Most recent synchronization error, or null when none is recorded.'), + lastSyncDocCount: z + .number() + .int() + .nonnegative() + .nullable() + .describe('Documents observed by the most recent synchronization.'), + nextSyncAt: v2TimestampSchema + .nullable() + .describe('Next scheduled synchronization time, or null when not scheduled.'), + consecutiveFailures: z + .number() + .int() + .nonnegative() + .describe('Number of consecutive synchronization failures.'), + createdAt: v2TimestampSchema.describe('Time the connector was created.'), + updatedAt: v2TimestampSchema.describe('Time the connector was last updated.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnector', + title: 'Knowledge connector', + description: 'An external document source linked to a knowledge base, without secret material.', + }) +export type V2KnowledgeConnector = z.output + +export const v2KnowledgeConnectorSyncLogSchema = z + .object({ + id: z.string().min(1).describe('Unique synchronization log identifier.'), + connectorId: z.string().min(1).describe('Connector that produced the log.'), + status: z.string().min(1).describe('Synchronization outcome or current state.'), + startedAt: v2TimestampSchema.describe('Time synchronization started.'), + completedAt: v2TimestampSchema + .nullable() + .describe('Time synchronization completed, or null while it is running.'), + docsAdded: z.number().int().nonnegative().describe('Documents added.'), + docsUpdated: z.number().int().nonnegative().describe('Documents updated.'), + docsDeleted: z.number().int().nonnegative().describe('Documents deleted.'), + docsUnchanged: z.number().int().nonnegative().describe('Documents unchanged.'), + docsFailed: z.number().int().nonnegative().describe('Documents that failed to synchronize.'), + errorMessage: z.string().nullable().describe('Synchronization error, or null.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnectorSyncLog', + title: 'Knowledge connector sync log', + description: 'One synchronization attempt for a knowledge connector.', + }) +export type V2KnowledgeConnectorSyncLog = z.output + +export const v2KnowledgeConnectorDetailSchema = v2KnowledgeConnectorSchema + .extend({ + syncLogs: z + .array(v2KnowledgeConnectorSyncLogSchema) + .describe('The ten most recent synchronization attempts.'), + }) + .meta({ + id: 'V2KnowledgeConnectorDetail', + title: 'Knowledge connector detail', + description: 'A knowledge connector and its recent synchronization history.', + }) +export type V2KnowledgeConnectorDetail = z.output + +export const v2KnowledgeConnectorDocumentSchema = z + .object({ + id: z.string().min(1).describe('Unique document identifier.'), + filename: z.string().min(1).describe('Document filename.'), + externalId: z.string().nullable().describe('Identifier assigned by the external source.'), + sourceUrl: z.string().nullable().describe('Original external source URL.'), + enabled: z.boolean().describe('Whether the document is enabled for knowledge search.'), + userExcluded: z + .boolean() + .describe('Whether a user explicitly excluded the document from connector sync results.'), + createdAt: v2TimestampSchema.describe('Time the document was first synchronized.'), + processingStatus: z.string().describe('Current document processing state.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnectorDocument', + title: 'Knowledge connector document', + description: 'A knowledge document produced by an external connector.', + }) +export type V2KnowledgeConnectorDocument = z.output + +export const v2KnowledgeConnectorParamsSchema = knowledgeConnectorParamsSchema + .extend({ + id: knowledgeConnectorParamsSchema.shape.id.describe('Knowledge base that owns the connector.'), + connectorId: knowledgeConnectorParamsSchema.shape.connectorId.describe( + 'Connector selected for the operation.' + ), + }) + .strict() +export type V2KnowledgeConnectorParams = z.output + +export const v2KnowledgeConnectorWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + }) + .strict() +export type V2KnowledgeConnectorWorkspaceQuery = z.output< + typeof v2KnowledgeConnectorWorkspaceQuerySchema +> + +export const v2KnowledgeConnectorSortFields = ['connectorType', 'createdAt', 'updatedAt'] as const + +export const v2ListKnowledgeConnectorsQuerySchema = v2KnowledgeConnectorWorkspaceQuerySchema + .extend({ + ...v2SortFields(v2KnowledgeConnectorSortFields, { + sortBy: 'createdAt', + sortOrder: 'desc', + }), + ...v2PaginationFields({ description: 'Maximum connectors to return per page.' }), + }) + .strict() +export type V2ListKnowledgeConnectorsQuery = z.output + +export const v2CreateKnowledgeConnectorBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + connectorType: z.string().trim().min(1).max(100).describe('Registered connector type.'), + credentialId: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe('OAuth credential identifier for connectors that require OAuth.'), + apiKey: z + .string() + .min(1) + .max(10_000) + .optional() + .describe('Write-only API key for connectors that use API-key authentication.'), + sourceConfig: z + .record(z.string(), z.unknown().describe('Connector-specific source configuration value.')) + .describe('Connector-specific source selection and filtering configuration.'), + syncIntervalMinutes: z + .number() + .int() + .min(0) + .max(525_600) + .default(1440) + .describe('Scheduled synchronization interval in minutes; zero disables scheduling.'), + }) + .strict() +export type V2CreateKnowledgeConnectorBody = z.input + +export const v2UpdateKnowledgeConnectorBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + sourceConfig: z + .record(z.string(), z.unknown().describe('Connector-specific source configuration value.')) + .optional() + .describe('Replacement source selection and filtering configuration.'), + syncIntervalMinutes: z + .number() + .int() + .min(0) + .max(525_600) + .optional() + .describe('New scheduled synchronization interval in minutes.'), + status: z.enum(['active', 'paused']).optional().describe('New connector state.'), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.sourceConfig === undefined && + body.syncIntervalMinutes === undefined && + body.status === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['sourceConfig'], + message: 'At least one of sourceConfig, syncIntervalMinutes, or status is required', + }) + } + }) +export type V2UpdateKnowledgeConnectorBody = z.input + +export const v2DeleteKnowledgeConnectorQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + deleteDocuments: booleanQueryFlagSchema + .optional() + .default(false) + .describe('Also permanently delete documents produced by this connector.'), + }) + .strict() +export type V2DeleteKnowledgeConnectorQuery = z.input + +export const v2SyncKnowledgeConnectorBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + rehydrate: z + .boolean() + .optional() + .default(false) + .describe('Re-fetch and re-index every existing connector document.'), + }) + .strict() +export type V2SyncKnowledgeConnectorBody = z.input + +export const v2ListKnowledgeConnectorDocumentsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + includeExcluded: booleanQueryFlagSchema + .optional() + .default(false) + .describe('Include documents explicitly excluded by a user.'), + ...v2PaginationFields({ description: 'Maximum connector documents to return per page.' }), + }) + .strict() +export type V2ListKnowledgeConnectorDocumentsQuery = z.output< + typeof v2ListKnowledgeConnectorDocumentsQuerySchema +> + +export const v2UpdateKnowledgeConnectorDocumentsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'), + operation: z + .enum(['restore', 'exclude']) + .describe('Whether to restore or exclude the selected documents.'), + documentIds: z + .array(z.string().min(1).max(255)) + .min(1, 'At least one document id is required') + .max(MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS) + .describe('Connector document identifiers to update.'), + }) + .strict() +export type V2UpdateKnowledgeConnectorDocumentsBody = z.input< + typeof v2UpdateKnowledgeConnectorDocumentsBodySchema +> + +export const v2KnowledgeConnectorDeleteDataSchema = z + .object({ + id: z.string().min(1).describe('Deleted connector identifier.'), + deleted: z.literal(true).describe('Whether the connector was deleted.'), + documentsDeleted: z.number().int().nonnegative().describe('Connector documents deleted.'), + documentsKept: z.number().int().nonnegative().describe('Connector documents retained.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnectorDeleteData', + title: 'Knowledge connector deletion data', + description: 'Connector deletion acknowledgement and affected document counts.', + }) +export type V2KnowledgeConnectorDeleteData = z.output + +export const v2KnowledgeConnectorSyncDataSchema = z + .object({ + id: z.string().min(1).describe('Connector queued for synchronization.'), + syncTriggered: z.literal(true).describe('Whether synchronization was queued.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnectorSyncData', + title: 'Knowledge connector sync data', + description: 'Acknowledgement that connector synchronization was queued.', + }) +export type V2KnowledgeConnectorSyncData = z.output + +export const v2KnowledgeConnectorDocumentsUpdateDataSchema = z + .object({ + operation: z.enum(['restore', 'exclude']).describe('Operation that was applied.'), + updatedCount: z.number().int().nonnegative().describe('Documents changed.'), + documentIds: z.array(z.string()).describe('Identifiers of documents changed.'), + }) + .strict() + .meta({ + id: 'V2KnowledgeConnectorDocumentsUpdateData', + title: 'Knowledge connector documents update data', + description: 'Outcome of restoring or excluding connector documents.', + }) +export type V2KnowledgeConnectorDocumentsUpdateData = z.output< + typeof v2KnowledgeConnectorDocumentsUpdateDataSchema +> + +export const v2ListKnowledgeConnectorsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors', + params: v2KnowledgeBaseParamsSchema, + query: v2ListKnowledgeConnectorsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorSchema) }, +}) + +export const v2CreateKnowledgeConnectorContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/connectors', + params: v2KnowledgeBaseParamsSchema, + query: noInputSchema, + body: v2CreateKnowledgeConnectorBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSchema), status: 201 }, +}) + +export const v2GetKnowledgeConnectorContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + params: v2KnowledgeConnectorParamsSchema, + query: v2KnowledgeConnectorWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDetailSchema) }, +}) + +export const v2UpdateKnowledgeConnectorContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + params: v2KnowledgeConnectorParamsSchema, + query: noInputSchema, + body: v2UpdateKnowledgeConnectorBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSchema) }, +}) + +export const v2DeleteKnowledgeConnectorContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + params: v2KnowledgeConnectorParamsSchema, + query: v2DeleteKnowledgeConnectorQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDeleteDataSchema) }, +}) + +export const v2SyncKnowledgeConnectorContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/sync', + params: v2KnowledgeConnectorParamsSchema, + query: noInputSchema, + body: v2SyncKnowledgeConnectorBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSyncDataSchema) }, +}) + +export const v2ListKnowledgeConnectorDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + params: v2KnowledgeConnectorParamsSchema, + query: v2ListKnowledgeConnectorDocumentsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorDocumentSchema) }, +}) + +export const v2UpdateKnowledgeConnectorDocumentsContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + params: v2KnowledgeConnectorParamsSchema, + query: noInputSchema, + body: v2UpdateKnowledgeConnectorDocumentsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeConnectorDocumentsUpdateDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 3ce43fea60e..3ac09dd84ef 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -3,21 +3,29 @@ import { v2BulkUpdateKnowledgeDocumentsContract, v2CompleteKnowledgeDocumentUploadContract, v2CreateKnowledgeBaseContract, + v2CreateKnowledgeConnectorContract, v2CreateKnowledgeDocumentUploadContract, v2CreateKnowledgeDocumentUploadPartUrlsContract, v2CreateKnowledgeFolderContract, v2DeleteKnowledgeBaseContract, + v2DeleteKnowledgeConnectorContract, v2DeleteKnowledgeDocumentContract, v2DeleteKnowledgeFolderContract, v2GetKnowledgeBaseContract, + v2GetKnowledgeConnectorContract, v2GetKnowledgeDocumentContract, v2ListKnowledgeBasesContract, + v2ListKnowledgeConnectorDocumentsContract, + v2ListKnowledgeConnectorsContract, v2ListKnowledgeDocumentsContract, v2ListKnowledgeFoldersContract, v2ListKnowledgeTagsContract, v2RelocateKnowledgeFolderContract, v2SearchKnowledgeContract, + v2SyncKnowledgeConnectorContract, v2UpdateKnowledgeBaseContract, + v2UpdateKnowledgeConnectorContract, + v2UpdateKnowledgeConnectorDocumentsContract, v2UpdateKnowledgeDocumentContract, v2UploadKnowledgeDocumentContract, v2UploadKnowledgeDocumentFormSchema, @@ -48,6 +56,36 @@ import { const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' const KNOWLEDGE_BASE_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7' +const KNOWLEDGE_CONNECTOR_ID = 'kc-9f8e7d6c' + +const KNOWLEDGE_CONNECTOR_EXAMPLE = { + id: KNOWLEDGE_CONNECTOR_ID, + knowledgeBaseId: KNOWLEDGE_BASE_ID, + connectorType: 'notion', + credentialId: 'cred-4b3a2c1d', + sourceConfig: { pageIds: ['page-123'] }, + syncMode: 'full', + syncIntervalMinutes: 1440, + status: 'active', + lastSyncAt: '2026-06-20T14:02:11.000Z', + lastSyncError: null, + lastSyncDocCount: 42, + nextSyncAt: '2026-06-21T14:02:11.000Z', + consecutiveFailures: 0, + createdAt: '2026-06-01T09:14:00.000Z', + updatedAt: '2026-06-20T14:02:11.000Z', +} as const + +const KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE = { + id: 'doc-8a7b6c5d', + filename: 'Product requirements', + externalId: 'page-123', + sourceUrl: 'https://www.notion.so/page-123', + enabled: true, + userExcluded: false, + createdAt: '2026-06-01T09:15:00.000Z', + processingStatus: 'completed', +} as const function knowledgeOperation( operation: Omit & { @@ -208,6 +246,293 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListKnowledgeConnectorsContract, + knowledgeOperation({ + operationId: 'listKnowledgeConnectors', + summary: 'List Knowledge Connectors', + description: `List external sources connected to a knowledge base with opaque cursor pagination. Stored API keys and encrypted secret material are never returned. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'A page of knowledge connectors.' }, + }), + { + params: documentedSchema( + v2ListKnowledgeConnectorsContract.params, + 'ListKnowledgeConnectorsParams', + 'List knowledge connectors path parameters', + 'Knowledge base whose connectors should be listed.' + ), + query: documentedSchema( + v2ListKnowledgeConnectorsContract.query, + 'ListKnowledgeConnectorsQuery', + 'List knowledge connectors query', + 'Workspace, sorting, and pagination controls.' + ), + response: documentedSchema( + v2ListKnowledgeConnectorsContract.response.schema, + 'V2KnowledgeConnectorListResponse', + 'Knowledge connector list response', + 'A cursor-paginated page of connectors without secret material.', + [{ data: [KNOWLEDGE_CONNECTOR_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateKnowledgeConnectorContract, + knowledgeOperation({ + operationId: 'createKnowledgeConnector', + summary: 'Create Knowledge Connector', + description: `Validate and connect an external source, then queue its initial synchronization. The apiKey field is write-only and is never returned. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The created connector without secret material.' }, + }), + { + query: v2CreateKnowledgeConnectorContract.query, + params: documentedSchema( + v2CreateKnowledgeConnectorContract.params, + 'CreateKnowledgeConnectorParams', + 'Create knowledge connector path parameters', + 'Knowledge base to connect to an external source.' + ), + body: documentedSchema( + v2CreateKnowledgeConnectorContract.body, + 'CreateKnowledgeConnectorRequest', + 'Create knowledge connector request', + 'Workspace, connector type, authentication reference, source configuration, and sync schedule.', + [ + { + workspaceId: WORKSPACE_ID, + connectorType: 'notion', + credentialId: 'cred-4b3a2c1d', + sourceConfig: { pageIds: ['page-123'] }, + syncIntervalMinutes: 1440, + }, + ] + ), + response: documentedSchema( + v2CreateKnowledgeConnectorContract.response.schema, + 'V2KnowledgeConnectorResponse', + 'Knowledge connector response', + 'A single connector without secret material.', + [{ data: KNOWLEDGE_CONNECTOR_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2GetKnowledgeConnectorContract, + knowledgeOperation({ + operationId: 'getKnowledgeConnector', + summary: 'Get Knowledge Connector', + description: `Retrieve one connector and its ten most recent synchronization attempts. Stored API keys and encrypted secret material are never returned. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The connector and recent synchronization history.' }, + }), + { + params: documentedSchema( + v2GetKnowledgeConnectorContract.params, + 'GetKnowledgeConnectorParams', + 'Get knowledge connector path parameters', + 'Knowledge connector selected for retrieval.' + ), + query: documentedSchema( + v2GetKnowledgeConnectorContract.query, + 'GetKnowledgeConnectorQuery', + 'Get knowledge connector query', + 'Workspace scope for the knowledge base.' + ), + response: documentedSchema( + v2GetKnowledgeConnectorContract.response.schema, + 'V2KnowledgeConnectorDetailResponse', + 'Knowledge connector detail response', + 'A connector and recent synchronization history without secret material.', + [{ data: { ...KNOWLEDGE_CONNECTOR_EXAMPLE, syncLogs: [] } }] + ), + } + ), + defineOpenApiRoute( + v2UpdateKnowledgeConnectorContract, + knowledgeOperation({ + operationId: 'updateKnowledgeConnector', + summary: 'Update Knowledge Connector', + description: `Update connector source configuration, schedule, or active state. Authentication material cannot be changed through this operation. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated connector.' }, + }), + { + query: v2UpdateKnowledgeConnectorContract.query, + params: documentedSchema( + v2UpdateKnowledgeConnectorContract.params, + 'UpdateKnowledgeConnectorParams', + 'Update knowledge connector path parameters', + 'Knowledge connector selected for update.' + ), + body: documentedSchema( + v2UpdateKnowledgeConnectorContract.body, + 'UpdateKnowledgeConnectorRequest', + 'Update knowledge connector request', + 'Workspace scope and at least one mutable connector field.', + [{ workspaceId: WORKSPACE_ID, status: 'paused' }] + ), + response: documentedSchema( + v2UpdateKnowledgeConnectorContract.response.schema, + 'V2KnowledgeConnectorResponse', + 'Knowledge connector response', + 'A single connector without secret material.', + [{ data: KNOWLEDGE_CONNECTOR_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2DeleteKnowledgeConnectorContract, + knowledgeOperation({ + operationId: 'deleteKnowledgeConnector', + summary: 'Delete Knowledge Connector', + description: `Delete a connector and optionally its synchronized documents. Documents are retained by default. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'Connector deletion acknowledgement and document counts.' }, + }), + { + params: documentedSchema( + v2DeleteKnowledgeConnectorContract.params, + 'DeleteKnowledgeConnectorParams', + 'Delete knowledge connector path parameters', + 'Knowledge connector selected for deletion.' + ), + query: documentedSchema( + v2DeleteKnowledgeConnectorContract.query, + 'DeleteKnowledgeConnectorQuery', + 'Delete knowledge connector query', + 'Workspace scope and whether synchronized documents should also be deleted.' + ), + response: documentedSchema( + v2DeleteKnowledgeConnectorContract.response.schema, + 'V2KnowledgeConnectorDeleteResponse', + 'Knowledge connector delete response', + 'Deletion acknowledgement and affected document counts.', + [ + { + data: { + id: KNOWLEDGE_CONNECTOR_ID, + deleted: true, + documentsDeleted: 0, + documentsKept: 42, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2SyncKnowledgeConnectorContract, + knowledgeOperation({ + operationId: 'syncKnowledgeConnector', + summary: 'Sync Knowledge Connector', + description: `Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Synchronization was queued.' }, + }), + { + query: v2SyncKnowledgeConnectorContract.query, + params: documentedSchema( + v2SyncKnowledgeConnectorContract.params, + 'SyncKnowledgeConnectorParams', + 'Sync knowledge connector path parameters', + 'Knowledge connector selected for synchronization.' + ), + body: documentedSchema( + v2SyncKnowledgeConnectorContract.body, + 'SyncKnowledgeConnectorRequest', + 'Sync knowledge connector request', + 'Workspace scope and optional full rehydration control.', + [{ workspaceId: WORKSPACE_ID, rehydrate: false }] + ), + response: documentedSchema( + v2SyncKnowledgeConnectorContract.response.schema, + 'V2KnowledgeConnectorSyncResponse', + 'Knowledge connector sync response', + 'Acknowledgement that synchronization was queued.', + [{ data: { id: KNOWLEDGE_CONNECTOR_ID, syncTriggered: true } }] + ), + } + ), + defineOpenApiRoute( + v2ListKnowledgeConnectorDocumentsContract, + knowledgeOperation({ + operationId: 'listKnowledgeConnectorDocuments', + summary: 'List Knowledge Connector Documents', + description: `List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'A page of connector documents.' }, + }), + { + params: documentedSchema( + v2ListKnowledgeConnectorDocumentsContract.params, + 'ListKnowledgeConnectorDocumentsParams', + 'List knowledge connector documents path parameters', + 'Knowledge connector whose documents should be listed.' + ), + query: documentedSchema( + v2ListKnowledgeConnectorDocumentsContract.query, + 'ListKnowledgeConnectorDocumentsQuery', + 'List knowledge connector documents query', + 'Workspace, exclusion filter, and pagination controls.' + ), + response: documentedSchema( + v2ListKnowledgeConnectorDocumentsContract.response.schema, + 'V2KnowledgeConnectorDocumentListResponse', + 'Knowledge connector document list response', + 'A cursor-paginated page of connector documents.', + [{ data: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2UpdateKnowledgeConnectorDocumentsContract, + knowledgeOperation({ + operationId: 'updateKnowledgeConnectorDocuments', + summary: 'Update Knowledge Connector Documents', + description: `Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The selected connector documents were updated.' }, + }), + { + query: v2UpdateKnowledgeConnectorDocumentsContract.query, + params: documentedSchema( + v2UpdateKnowledgeConnectorDocumentsContract.params, + 'UpdateKnowledgeConnectorDocumentsParams', + 'Update knowledge connector documents path parameters', + 'Knowledge connector whose documents should be updated.' + ), + body: documentedSchema( + v2UpdateKnowledgeConnectorDocumentsContract.body, + 'UpdateKnowledgeConnectorDocumentsRequest', + 'Update knowledge connector documents request', + 'Workspace, restore or exclude operation, and selected document identifiers.', + [ + { + workspaceId: WORKSPACE_ID, + operation: 'exclude', + documentIds: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE.id], + }, + ] + ), + response: documentedSchema( + v2UpdateKnowledgeConnectorDocumentsContract.response.schema, + 'V2KnowledgeConnectorDocumentsUpdateResponse', + 'Knowledge connector documents update response', + 'Operation result and identifiers actually changed.', + [ + { + data: { + operation: 'exclude', + updatedCount: 1, + documentIds: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE.id], + }, + }, + ] + ), + } + ), defineOpenApiRoute( v2SearchKnowledgeContract, knowledgeOperation({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 50ebb34a817..dd68e8eb1ab 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -45,12 +45,16 @@ import { v2CreateSkillContract, v2DeleteSkillContract, v2GetSkillContract, + v2GrantSkillEditorContract, + v2ListSkillEditorsContract, v2ListSkillsContract, + v2RevokeSkillEditorContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' import { v2GetWorkspaceContract, v2ListWorkspaceMembersContract, + v2ListWorkspacesContract, } from '@/lib/api/contracts/v2/workspaces' import { defineOpenApiDocument, @@ -137,6 +141,13 @@ const SKILL_EXAMPLE = { content: '# Refund policy\n\nAlways check the order date first.', } as const +const SKILL_EDITOR_EXAMPLE = { + email: 'jane@example.com', + name: 'Jane Smith', + image: null, + isWorkspaceAdmin: false, +} as const + const CUSTOM_TOOL_DECLARATION_EXAMPLE = { type: 'function', function: { @@ -278,6 +289,32 @@ function resourceOperation( } const declaredRoutes = [ + defineOpenApiRoute( + v2ListWorkspacesContract, + resourceOperation('Workspaces', { + operationId: 'listWorkspaces', + summary: 'List Workspaces', + description: + 'List active workspaces available to the API key with opaque cursor pagination. A personal API key sees every accessible workspace that permits personal API keys; a workspace API key sees only its bound workspace.', + errors: RESOURCE_ERRORS, + success: { description: 'Public metadata for workspaces available to the API key.' }, + }), + { + query: documentedSchema( + v2ListWorkspacesContract.query, + 'ListWorkspacesQuery', + 'List workspaces query', + 'Sorting and pagination controls for accessible workspaces.' + ), + response: documentedSchema( + v2ListWorkspacesContract.response.schema, + 'ListWorkspacesResponse', + 'List workspaces response', + 'Public metadata for workspaces available to the API key.', + [{ data: [WORKSPACE_EXAMPLE], nextCursor: null }] + ), + } + ), defineOpenApiRoute( v2GetWorkspaceContract, resourceOperation('Workspaces', { @@ -684,6 +721,107 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListSkillEditorsContract, + resourceOperation('Skills', { + operationId: 'listSkillEditors', + summary: 'List Skill Editors', + description: + 'List explicit skill editors and workspace administrators with opaque cursor pagination. Internal user and membership identifiers are never returned.', + errors: RESOURCE_ERRORS, + success: { description: 'Users who can edit the skill.' }, + }), + { + params: documentedSchema( + v2ListSkillEditorsContract.params, + 'ListSkillEditorsParams', + 'List skill editors path parameters', + 'Skill whose editor roster should be listed.' + ), + query: documentedSchema( + v2ListSkillEditorsContract.query, + 'ListSkillEditorsQuery', + 'List skill editors query', + 'Workspace, sorting, and pagination controls for the editor roster.' + ), + response: documentedSchema( + v2ListSkillEditorsContract.response.schema, + 'ListSkillEditorsResponse', + 'List skill editors response', + 'Public identity fields for users who can edit the skill.', + [{ data: [SKILL_EDITOR_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GrantSkillEditorContract, + resourceOperation('Skills', { + operationId: 'grantSkillEditor', + summary: 'Grant Skill Editor', + description: `Grant editor access to a current workspace member by email. The caller must already be a skill editor or workspace administrator. Workspace administrators already have derived editor access and cannot receive an explicit grant. A retried existing grant returns 200; a newly created grant returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { + byStatus: { + 200: { description: 'The workspace member was already a skill editor.' }, + 201: { description: 'The skill editor grant was created.' }, + }, + }, + }), + { + query: v2GrantSkillEditorContract.query, + params: documentedSchema( + v2GrantSkillEditorContract.params, + 'GrantSkillEditorParams', + 'Grant skill editor path parameters', + 'Skill whose editor roster should be changed.' + ), + body: documentedSchema( + v2GrantSkillEditorContract.body, + 'GrantSkillEditorRequest', + 'Grant skill editor request', + 'Workspace scope and email of the member to grant.', + [{ workspaceId: WORKSPACE_ID, email: SKILL_EDITOR_EXAMPLE.email }] + ), + response: documentedSchema( + v2GrantSkillEditorContract.response.schema, + 'GrantSkillEditorResponse', + 'Grant skill editor response', + 'Public identity fields for the editor.', + [{ data: SKILL_EDITOR_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2RevokeSkillEditorContract, + resourceOperation('Skills', { + operationId: 'revokeSkillEditor', + summary: 'Revoke Skill Editor', + description: `Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The explicit editor grant was revoked.' }, + }), + { + params: documentedSchema( + v2RevokeSkillEditorContract.params, + 'RevokeSkillEditorParams', + 'Revoke skill editor path parameters', + 'Skill whose editor roster should be changed.' + ), + query: documentedSchema( + v2RevokeSkillEditorContract.query, + 'RevokeSkillEditorQuery', + 'Revoke skill editor query', + 'Workspace scope and email whose explicit grant should be revoked.' + ), + response: documentedSchema( + v2RevokeSkillEditorContract.response.schema, + 'RevokeSkillEditorResponse', + 'Revoke skill editor response', + 'Acknowledgement that the explicit editor grant was revoked.', + [{ data: { email: SKILL_EDITOR_EXAMPLE.email, revoked: true } }] + ), + } + ), defineOpenApiRoute( v2ListCustomToolsContract, resourceOperation('Custom Tools', { @@ -903,7 +1041,7 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'createServiceAccountCredential', summary: 'Create Service-Account Credential', - description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { byStatus: { @@ -918,16 +1056,15 @@ const declaredRoutes = [ v2CreateServiceAccountCredentialContract.body, 'CreateServiceAccountCredentialRequest', 'Create service-account credential request', - 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + 'Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.', [ { workspaceId: WORKSPACE_ID, type: 'service_account', providerId: 'zoom-service-account', displayName: 'Zoom automation', - clientId: 'YOUR_CLIENT_ID', - clientSecret: 'YOUR_CLIENT_SECRET', - orgId: 'YOUR_ACCOUNT_ID', + credentials: + '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET","orgId":"YOUR_ACCOUNT_ID"}', }, ] ), diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 03dd5a0f230..e4fd1fc7943 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -90,6 +90,34 @@ export const v2SkillDeleteDataSchema = z }) export type V2SkillDeleteData = z.output +export const v2SkillEditorSchema = z + .object({ + email: z.string().email().describe('Email address of the skill editor.'), + name: z.string().nullable().describe('Display name of the skill editor.'), + image: z.string().nullable().describe('Profile image URL of the skill editor.'), + isWorkspaceAdmin: z + .boolean() + .describe('Whether editor access is derived from workspace administration.'), + }) + .meta({ + id: 'V2SkillEditor', + title: 'Skill editor', + description: 'Public identity fields for a user who can edit a skill.', + }) +export type V2SkillEditor = z.output + +export const v2SkillEditorDeleteDataSchema = z + .object({ + email: z.string().email().describe('Email address whose explicit editor grant was revoked.'), + revoked: z.literal(true).describe('Whether the explicit editor grant was revoked.'), + }) + .meta({ + id: 'V2SkillEditorDeleteData', + title: 'Revoke skill editor data', + description: 'Skill editor revocation acknowledgement.', + }) +export type V2SkillEditorDeleteData = z.output + export const v2SkillParamsSchema = z.object({ id: nonEmptyIdSchema.describe( 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.' @@ -118,6 +146,39 @@ export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema export type V2ListSkillsQuery = z.output +export const v2SkillEditorSortFields = ['email', 'name'] as const +export type V2SkillEditorSortBy = (typeof v2SkillEditorSortFields)[number] + +export const v2ListSkillEditorsQuerySchema = v2SkillWorkspaceQuerySchema + .extend({ + ...v2SortFields(v2SkillEditorSortFields, { sortBy: 'email', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum skill editors to return per page.' }), + }) + .strict() +export type V2ListSkillEditorsQuery = z.output + +const skillEditorEmailSchema = z + .string() + .trim() + .email('A valid editor email is required') + .describe('Email address of a current workspace member.') + +export const v2GrantSkillEditorBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), + email: skillEditorEmailSchema, + }) + .strict() +export type V2GrantSkillEditorBody = z.input + +export const v2RevokeSkillEditorQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'), + email: skillEditorEmailSchema, + }) + .strict() +export type V2RevokeSkillEditorQuery = z.input + /** * Create body. Every field is required, so each one carries the missing-value * wording the shared field primitives cannot: those are also spelled `.optional()` @@ -228,3 +289,38 @@ export const v2DeleteSkillContract = defineRouteContract({ schema: v2DataResponse(v2SkillDeleteDataSchema), }, }) + +export const v2ListSkillEditorsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills/[id]/editors', + params: v2SkillParamsSchema, + query: v2ListSkillEditorsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SkillEditorSchema), + }, +}) + +export const v2GrantSkillEditorContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/skills/[id]/editors', + params: v2SkillParamsSchema, + query: noInputSchema, + body: v2GrantSkillEditorBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillEditorSchema), + status: [200, 201], + }, +}) + +export const v2RevokeSkillEditorContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/skills/[id]/editors', + params: v2SkillParamsSchema, + query: v2RevokeSkillEditorQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillEditorDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index c85fbcdd001..355d4e02945 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -5,6 +5,7 @@ import { v2CursorListResponse, v2DataResponse, v2PaginationFields, + v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' @@ -37,6 +38,17 @@ export const v2WorkspaceSchema = z }) export type V2Workspace = z.output +export const v2WorkspaceSortFields = ['name', 'createdAt', 'updatedAt'] as const +export type V2WorkspaceSortBy = (typeof v2WorkspaceSortFields)[number] + +export const v2ListWorkspacesQuerySchema = z + .object({ + ...v2SortFields(v2WorkspaceSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), + ...v2PaginationFields({ description: 'Maximum workspaces to return per page.' }), + }) + .strict() +export type V2ListWorkspacesQuery = z.output + export const v2WorkspaceMemberSchema = z .object({ email: z.email().describe('Member email address and public member identifier.'), @@ -77,6 +89,13 @@ export const v2GetWorkspaceContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkspaceSchema) }, }) +export const v2ListWorkspacesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces', + query: v2ListWorkspacesQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2WorkspaceSchema) }, +}) + export const v2ListWorkspaceMembersContract = defineRouteContract({ method: 'GET', path: '/api/v2/workspaces/[workspaceId]/members', diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 243cbfc12b5..8947d91829e 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -534,8 +534,11 @@ describe('knowledge connector application use cases', () => { { id: 'document-4', filename: 'd.txt', userExcluded: true }, ], counts: { active: 5, excluded: 2 }, + hasMore: false, + offset: 2, + limit: 2, }) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) expect(dbChainMockFns.offset).toHaveBeenCalledWith(2) }) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 536c905e8f4..9a591df3f5b 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -12,7 +12,10 @@ import { resolveCredentialTokenIdentity, } from '@/lib/credentials/access' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' -import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + resolveKnowledgeAttributedUserId, + resolveKnowledgeBillingAttribution, +} from '@/lib/knowledge/application/billing' import { type ActiveKnowledgeResourceBaseContext, resolveActiveKnowledgeConnectorContext, @@ -46,6 +49,10 @@ interface KnowledgeConnectorApplicationInput { export interface ListKnowledgeConnectorsInput extends KnowledgeConnectorApplicationInput { knowledgeBaseId: string + sortBy?: 'connectorType' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + offset?: number } export interface ReadKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { @@ -60,7 +67,7 @@ export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplica apiKey?: string sourceConfig: Record syncIntervalMinutes: number - resolveBillingAttribution(workspaceId: string): Promise + resolveBillingAttribution?(workspaceId: string): Promise } export interface UpdateKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { @@ -80,7 +87,7 @@ export interface DeleteKnowledgeConnectorInput extends KnowledgeConnectorApplica export interface SyncKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { connectorId: string rehydrate?: boolean - resolveBillingAttribution(workspaceId: string): Promise + resolveBillingAttribution?(workspaceId: string): Promise } export interface ListKnowledgeConnectorDocumentsInput extends ReadKnowledgeConnectorInput { @@ -224,8 +231,15 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listConnectors, resolveContext: ({ input }: { input: ListKnowledgeConnectorsInput }) => resolveActiveKnowledgeResourceContext(input), - async execute({ context }) { - const connectors = await db + async execute({ input, context }) { + const sortOrder = input.sortOrder === 'asc' ? asc : desc + const sortColumn = + input.sortBy === 'connectorType' + ? knowledgeConnector.connectorType + : input.sortBy === 'updatedAt' + ? knowledgeConnector.updatedAt + : knowledgeConnector.createdAt + const orderedQuery = db .select() .from(knowledgeConnector) .where( @@ -235,8 +249,20 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({ isNull(knowledgeConnector.deletedAt) ) ) - .orderBy(desc(knowledgeConnector.createdAt)) - return { connectors: connectors.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => rest) } + .orderBy(sortOrder(sortColumn), sortOrder(knowledgeConnector.id)) + const offset = input.offset ?? 0 + const rows = + input.limit === undefined + ? await orderedQuery + : await orderedQuery.limit(input.limit + 1).offset(offset) + const hasMore = input.limit !== undefined && rows.length > input.limit + const page = input.limit === undefined ? rows : rows.slice(0, input.limit) + return { + connectors: page.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => rest), + hasMore, + offset, + limit: input.limit ?? page.length, + } }, }) @@ -273,7 +299,9 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ apiKey: input.apiKey, sourceConfig: input.sourceConfig, syncIntervalMinutes: input.syncIntervalMinutes, - resolveBillingAttribution: () => input.resolveBillingAttribution(workspaceId), + resolveBillingAttribution: () => + input.resolveBillingAttribution?.(workspaceId) ?? + resolveKnowledgeBillingAttribution(principal, context), resolveAccessToken: (credentialId) => resolveConnectorCredentialAccessToken({ credentialId, @@ -413,7 +441,9 @@ export const syncKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ const outcome = await performSyncKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorId: context.connectorId, - resolveBillingAttribution: () => input.resolveBillingAttribution(workspaceId), + resolveBillingAttribution: () => + input.resolveBillingAttribution?.(workspaceId) ?? + resolveKnowledgeBillingAttribution(principal, context), rehydrate: input.rehydrate, userId: resolveKnowledgeAttributedUserId(principal, context), source: input.source ?? 'agent', @@ -499,18 +529,23 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase( : Promise.resolve([{ value: 0 }]), ]) const excludedCount = excludedCountRows[0] - const documents = await db + const rows = await db .select(connectorDocumentSelection) .from(document) .where( and(...baseConditions, input.includeExcluded ? undefined : eq(document.userExcluded, false)) ) .orderBy(asc(document.userExcluded), asc(document.filename)) - .limit(limit) + .limit(limit + 1) .offset(offset) + const hasMore = rows.length > limit + const documents = rows.slice(0, limit) return { documents, counts: { active: activeCount?.value ?? 0, excluded: excludedCount?.value ?? 0 }, + hasMore, + offset, + limit, } }, }) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index a37819c0a98..69bfd883860 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -104,10 +104,14 @@ describe('knowledge operation registry', () => { knowledgeOperations.updateTag, knowledgeOperations.deleteTag, knowledgeOperations.readTagUsage, + knowledgeOperations.listConnectors, + knowledgeOperations.readConnector, knowledgeOperations.createConnector, knowledgeOperations.updateConnector, knowledgeOperations.deleteConnector, knowledgeOperations.syncConnector, + knowledgeOperations.listConnectorDocuments, + knowledgeOperations.updateConnectorDocuments, ] for (const operation of operations) { expect(operation.workspaceApiKey).toBe('deny') diff --git a/apps/sim/lib/skills/application/editor-use-cases.test.ts b/apps/sim/lib/skills/application/editor-use-cases.test.ts new file mode 100644 index 00000000000..09cbd9e0a14 --- /dev/null +++ b/apps/sim/lib/skills/application/editor-use-cases.test.ts @@ -0,0 +1,291 @@ +/** + * @vitest-environment node + */ +import { skill } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + listEditors: vi.fn(), + listWorkspaceMembers: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + SKILL_CREATED: 'skill.created', + SKILL_UPDATED: 'skill.updated', + SKILL_DELETED: 'skill.deleted', + SKILL_MEMBER_ADDED: 'skill.member_added', + SKILL_MEMBER_REMOVED: 'skill.member_removed', + }, + AuditResourceType: { SKILL: 'skill' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/skills/access', () => ({ + getSkillActorContext: mocks.getActor, + listSkillEditors: mocks.listEditors, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + createSkill: vi.fn(), + deleteSkillRecord: vi.fn(), + updateSkill: vi.fn(), + upsertSkillBatch: vi.fn(), +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: vi.fn(), + listSkillSummariesPage: vi.fn(), + listSkillsForUser: vi.fn(), +})) + +vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ + isBuiltinSkillId: (id: string) => id.startsWith('builtin-'), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUsersWithPermissions: mocks.listWorkspaceMembers, +})) + +import { + grantSkillEditorUseCase, + listSkillEditorsUseCase, + revokeSkillEditorUseCase, +} from '@/lib/skills/application/use-cases' + +const WORKSPACE_ID = 'workspace-1' +const SKILL_ID = 'skill-1' +const ACTOR_ID = 'user-1' +const TARGET_ID = 'user-2' +const TARGET_EMAIL = 'ada@example.com' +const principal = { kind: 'personal_api_key' as const, userId: ACTOR_ID, keyId: 'key-1' } +const skillRow = { + id: SKILL_ID, + workspaceId: WORKSPACE_ID, + userId: ACTOR_ID, + name: 'research', + description: 'Research instructions', + content: 'Investigate the topic.', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const targetMember = { + userId: TARGET_ID, + email: TARGET_EMAIL, + name: 'Ada', + image: null, + permissionType: 'write', + isExternal: false, + joinedAt: '2026-01-01T00:00:00.000Z', + roleSource: 'explicit', + isOrgAdmin: false, + isBilledAccount: false, +} +const targetEditor = { + id: 'skill-member-1', + userId: TARGET_ID, + userName: targetMember.name, + userEmail: targetMember.email, + userImage: targetMember.image, + isWorkspaceAdmin: false, +} + +function queueSkill(): void { + queueTableRows(skill, [skillRow]) +} + +describe('skill editor application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getActor.mockResolvedValue({ + skill: skillRow, + hasWorkspaceAccess: true, + canEdit: true, + }) + mocks.listEditors.mockResolvedValue([]) + mocks.listWorkspaceMembers.mockResolvedValue([targetMember]) + }) + + afterAll(resetDbChainMock) + + it('lists and paginates the full editor roster after workspace authorization', async () => { + queueSkill() + mocks.listEditors.mockResolvedValue([ + targetEditor, + { + ...targetEditor, + id: 'workspace-admin-user-3', + userId: 'user-3', + userName: 'Grace', + userEmail: 'grace@example.com', + isWorkspaceAdmin: true, + }, + ]) + + const result = await listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + sortBy: 'name', + sortOrder: 'desc', + limit: 1, + offset: 0, + }, + }) + + expect(result.editors.map(({ userName }) => userName)).toEqual(['Grace']) + expect(result.hasMore).toBe(true) + expect(mocks.resolvePermission).toHaveBeenCalled() + }) + + it('creates an explicit grant and audits the authoritative result', async () => { + queueSkill() + dbChainMockFns.returning.mockResolvedValueOnce([{ id: targetEditor.id }]) + + const result = await grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + + expect(result).toMatchObject({ created: true, editor: targetEditor }) + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + action: 'skill.member_added', + metadata: expect.objectContaining({ targetUserId: TARGET_ID }), + }) + ) + }) + + it('treats an existing editor grant as an unaudited idempotent success', async () => { + queueSkill() + mocks.listEditors.mockResolvedValue([targetEditor]) + + const result = await grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + + expect(result.created).toBe(false) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('requires the actor to be a current editor before resolving the target', async () => { + queueSkill() + mocks.getActor.mockResolvedValue({ + skill: skillRow, + hasWorkspaceAccess: true, + canEdit: false, + }) + + await expect( + grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden', detailCode: 'SKILL_EDITOR_ACCESS_REQUIRED' }) + + expect(mocks.listWorkspaceMembers).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('refuses explicit grants for derived workspace administrators', async () => { + queueSkill() + mocks.listWorkspaceMembers.mockResolvedValue([ + { ...targetMember, permissionType: 'admin', roleSource: 'org-admin', isOrgAdmin: true }, + ]) + + await expect( + grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('returns not-found when revoking a member without an explicit grant', async () => { + queueSkill() + + await expect( + revokeSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('conceals an asserted workspace mismatch before workspace authorization', async () => { + queueSkill() + + await expect( + listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-2', + skillId: SKILL_ID, + sortBy: 'email', + sortOrder: 'asc', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 1d85523dcc5..5669aadb134 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -80,6 +80,21 @@ describe('skill operation registry', () => { ).toThrow(/does not represent a human subject/) }) + it('allows workspace keys to read editor rosters but keeps roster mutations personal', () => { + expect(skillOperations.listEditors).toMatchObject({ + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }) + for (const operation of [skillOperations.grantEditor, skillOperations.revokeEditor]) { + expect(operation).toMatchObject({ + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }) + } + }) + it('uses unique stable operation IDs', () => { const ids = Object.values(skillOperations).map((operation) => operation.id) expect(new Set(ids).size).toBe(ids.length) diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index aa13c8f9a93..32ed520ccad 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -8,6 +8,12 @@ const HUMAN_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const HTTP_SKILL_EDITOR_READ_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], +} as const +const HUMAN_HTTP_SKILL_EDITOR_POLICY = { + principalKinds: ['session', 'personal_api_key'], +} as const /** * Every skill write is human-subject-only. Reads are not. @@ -83,6 +89,24 @@ export const skillOperations = { workspaceApiKey: 'deny', ...HUMAN_PRINCIPAL_POLICY, }), + listEditors: defineWorkspaceOperation({ + id: 'skills.editors.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...HTTP_SKILL_EDITOR_READ_POLICY, + }), + grantEditor: defineWorkspaceOperation({ + id: 'skills.editors.grant', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_HTTP_SKILL_EDITOR_POLICY, + }), + revokeEditor: defineWorkspaceOperation({ + id: 'skills.editors.revoke', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_HTTP_SKILL_EDITOR_POLICY, + }), } as const export type SkillOperation = (typeof skillOperations)[keyof typeof skillOperations] diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 251a2dc95b3..65499c817bb 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -1,12 +1,17 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' -import type { skill } from '@sim/db/schema' +import { db } from '@sim/db' +import { skill, skillMember } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' import type { ListSortOrder } from '@/lib/api/list-query' import { authorizeWorkspaceOperation, defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getSkillActorContext, listSkillEditors, type SkillEditor } from '@/lib/skills/access' import { skillDelegationPolicy } from '@/lib/skills/application/authorization' import { skillOperations } from '@/lib/skills/application/operations' import { @@ -17,12 +22,14 @@ import { upsertSkillBatch, } from '@/lib/skills/orchestration' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { getSkillById, listSkillSummariesPage, listSkillsForUser, type SkillSortBy, } from '@/lib/workflows/skills/operations' +import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' type SkillRow = typeof skill.$inferSelect type SkillWriteSource = 'api' | 'settings' | 'tool_input' @@ -51,8 +58,78 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } +async function resolveSkillEditorContext( + skillId: string, + assertedWorkspaceId?: string +): Promise { + if (isBuiltinSkillId(skillId)) throw new OrchestrationError('not_found', 'Skill not found') + + const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) + if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) { + throw new OrchestrationError('not_found', 'Skill not found') + } + + const workspace = await resolveWorkspaceContext(row.workspaceId) + return { ...workspace, skill: row } +} + const authorizationOptions = { delegation: skillDelegationPolicy } +async function requireSkillEditorAccess(userId: string, context: SkillContext): Promise { + const actor = await getSkillActorContext(context.skill.id, userId) + if ( + !actor.skill || + actor.skill.workspaceId !== context.workspaceId || + !actor.hasWorkspaceAccess + ) { + throw new OrchestrationError('not_found', 'Skill not found') + } + if (!actor.canEdit) { + throw new ForbiddenOperationError( + 'SKILL_EDITOR_ACCESS_REQUIRED', + 'Skill editor access required' + ) + } +} + +export type SkillEditorTarget = + | { kind: 'user_id'; userId: string } + | { kind: 'email'; email: string } + +async function resolveSkillEditorTarget( + context: SkillContext, + target: SkillEditorTarget +): Promise { + const editors = await listSkillEditors({ + id: context.skill.id, + workspaceId: context.workspaceId, + }) + const workspaceMembers = await getUsersWithPermissions(context.workspaceId) + const member = + target.kind === 'user_id' + ? workspaceMembers.find(({ userId }) => userId === target.userId) + : workspaceMembers.find( + ({ email }) => email.toLowerCase() === target.email.trim().toLowerCase() + ) + if (!member) { + throw new OrchestrationError('validation', 'User is not a member of this workspace') + } + if (member.permissionType === 'admin') { + throw new OrchestrationError('validation', 'Workspace admins can always edit skills') + } + const existing = editors.find(({ userId }) => userId === member.userId) + return ( + existing ?? { + id: '', + userId: member.userId, + userName: member.name, + userEmail: member.email, + userImage: member.image, + isWorkspaceAdmin: false, + } + ) +} + export interface ListSkillsInput { workspaceId: string search?: string @@ -278,3 +355,140 @@ export const deleteSkillUseCase = defineAuthorizedWorkspaceUseCase({ metadata: { source: input.source }, }), }) + +export interface ListSkillEditorsInput { + workspaceId?: string + skillId: string + sortBy: 'email' | 'name' + sortOrder: ListSortOrder + limit?: number + offset?: number +} + +export const listSkillEditorsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.listEditors, + resolveContext: ({ input }: { input: ListSkillEditorsInput }) => + resolveSkillEditorContext(input.skillId, input.workspaceId), + authorizationOptions, + async execute({ input, context }) { + const editors = await listSkillEditors({ + id: context.skill.id, + workspaceId: context.workspaceId, + }) + const direction = input.sortOrder === 'asc' ? 1 : -1 + const sorted = editors.sort((left, right) => { + const leftValue = input.sortBy === 'email' ? (left.userEmail ?? '') : (left.userName ?? '') + const rightValue = input.sortBy === 'email' ? (right.userEmail ?? '') : (right.userName ?? '') + const primary = leftValue.localeCompare(rightValue) + return primary === 0 + ? left.userId.localeCompare(right.userId) * direction + : primary * direction + }) + if (input.limit === undefined) { + return { editors: sorted, hasMore: false, offset: 0, limit: sorted.length } + } + const offset = input.offset ?? 0 + return { + editors: sorted.slice(offset, offset + input.limit), + hasMore: sorted.length > offset + input.limit, + offset, + limit: input.limit, + } + }, +}) + +export interface GrantSkillEditorInput { + workspaceId?: string + skillId: string + target: SkillEditorTarget +} + +export const grantSkillEditorUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.grantEditor, + resolveContext: ({ input }: { input: GrantSkillEditorInput }) => + resolveSkillEditorContext(input.skillId, input.workspaceId), + authorizationOptions, + authorizeResource: ({ principal, context }) => + requireSkillEditorAccess(requirePrincipalSubjectUserId(principal), context), + async execute({ principal, input, context }) { + const target = await resolveSkillEditorTarget(context, input.target) + if (target.id) return { editor: target, created: false, workspaceId: context.workspaceId } + + const now = new Date() + const [inserted] = await db + .insert(skillMember) + .values({ + id: generateId(), + skillId: context.skill.id, + userId: target.userId, + invitedBy: requirePrincipalSubjectUserId(principal), + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) + .returning({ id: skillMember.id }) + + if (inserted) { + return { + editor: { ...target, id: inserted.id }, + created: true, + workspaceId: context.workspaceId, + } + } + + const [concurrent] = await db + .select({ id: skillMember.id }) + .from(skillMember) + .where(and(eq(skillMember.skillId, context.skill.id), eq(skillMember.userId, target.userId))) + .limit(1) + if (!concurrent) throw new Error('Skill editor insert conflicted without an existing row') + return { + editor: { ...target, id: concurrent.id }, + created: false, + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result, context }) => + result.created + ? { + action: AuditAction.SKILL_MEMBER_ADDED, + resourceType: AuditResourceType.SKILL, + resourceId: context.skill.id, + resourceName: context.skill.name, + description: 'Added skill editor', + metadata: { targetUserId: result.editor.userId }, + } + : [], +}) + +export interface RevokeSkillEditorInput { + workspaceId?: string + skillId: string + target: SkillEditorTarget +} + +export const revokeSkillEditorUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.revokeEditor, + resolveContext: ({ input }: { input: RevokeSkillEditorInput }) => + resolveSkillEditorContext(input.skillId, input.workspaceId), + authorizationOptions, + authorizeResource: ({ principal, context }) => + requireSkillEditorAccess(requirePrincipalSubjectUserId(principal), context), + async execute({ input, context }) { + const target = await resolveSkillEditorTarget(context, input.target) + const [removed] = await db + .delete(skillMember) + .where(and(eq(skillMember.skillId, context.skill.id), eq(skillMember.userId, target.userId))) + .returning({ id: skillMember.id }) + if (!removed) throw new OrchestrationError('not_found', 'Editor not found') + return { editor: target, workspaceId: context.workspaceId } + }, + projectAudit: ({ result, context }) => ({ + action: AuditAction.SKILL_MEMBER_REMOVED, + resourceType: AuditResourceType.SKILL, + resourceId: context.skill.id, + resourceName: context.skill.name, + description: 'Removed skill editor', + metadata: { targetUserId: result.editor.userId }, + }), +}) diff --git a/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts b/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts new file mode 100644 index 00000000000..7956cd63c5d --- /dev/null +++ b/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listAccessible: vi.fn(), + getDetail: vi.fn(), + loadContext: vi.fn(), +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: mocks.listAccessible, +})) +vi.mock('@/lib/workspaces/public-queries', () => ({ + getPublicWorkspaceDetail: mocks.getDetail, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadContext, +})) + +import { listPublicWorkspaces } from '@/lib/workspaces/application/list-public-workspaces' + +const workspace = (id: string, name: string, allowPersonalApiKeys: boolean, day: number) => ({ + id, + name, + allowPersonalApiKeys, + createdAt: new Date(`2026-01-${String(day).padStart(2, '0')}T00:00:00Z`), + updatedAt: new Date(`2026-02-${String(day).padStart(2, '0')}T00:00:00Z`), +}) + +describe('listPublicWorkspaces', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getDetail.mockImplementation(async (id: string) => ({ + id, + name: id, + color: '#33C482', + logoUrl: null, + memberCount: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + })) + }) + + it('lists every accessible workspace where personal keys are enabled', async () => { + mocks.listAccessible.mockResolvedValue([ + { + workspace: workspace('workspace-b', 'Beta', true, 2), + permissionType: 'read', + viaOrgAdmin: false, + }, + { + workspace: workspace('workspace-disabled', 'Disabled', false, 3), + permissionType: 'admin', + viaOrgAdmin: true, + }, + { + workspace: workspace('workspace-a', 'Alpha', true, 1), + permissionType: 'write', + viaOrgAdmin: false, + }, + ]) + + const result = await listPublicWorkspaces.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { sortBy: 'name', sortOrder: 'asc', limit: 1, offset: 0 }, + }) + + expect(result.workspaces.map(({ id }) => id)).toEqual(['workspace-a']) + expect(result.hasMore).toBe(true) + expect(mocks.getDetail).not.toHaveBeenCalledWith('workspace-disabled') + }) + + it('limits a workspace key to its bound active workspace', async () => { + mocks.loadContext.mockResolvedValue({ + workspaceId: 'workspace-bound', + workspaceOrganizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'owner-1', + }) + + const result = await listPublicWorkspaces.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-bound', + keyId: 'key-1', + }, + input: { sortBy: 'createdAt', sortOrder: 'desc', limit: 50, offset: 0 }, + }) + + expect(result.workspaces.map(({ id }) => id)).toEqual(['workspace-bound']) + expect(mocks.listAccessible).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspaces/application/list-public-workspaces.ts b/apps/sim/lib/workspaces/application/list-public-workspaces.ts new file mode 100644 index 00000000000..3651725533f --- /dev/null +++ b/apps/sim/lib/workspaces/application/list-public-workspaces.ts @@ -0,0 +1,91 @@ +import type { ListSortOrder } from '@/lib/api/list-query' +import { + authorizeWorkspaceOperation, + type OperationUseCase, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + getPublicWorkspaceDetail, + type PublicWorkspaceDetail, +} from '@/lib/workspaces/public-queries' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' + +export interface ListPublicWorkspacesInput { + sortBy: 'name' | 'createdAt' | 'updatedAt' + sortOrder: ListSortOrder + limit: number + offset: number +} + +export interface ListPublicWorkspacesResult { + workspaces: PublicWorkspaceDetail[] + hasMore: boolean + offset: number + limit: number +} + +type WorkspaceRow = Awaited< + ReturnType +>[number]['workspace'] + +function compareWorkspaceRows( + left: WorkspaceRow, + right: WorkspaceRow, + sortBy: ListPublicWorkspacesInput['sortBy'], + sortOrder: ListSortOrder +): number { + const direction = sortOrder === 'asc' ? 1 : -1 + const primary = + sortBy === 'name' + ? left.name.localeCompare(right.name) + : left[sortBy].getTime() - right[sortBy].getTime() + return primary === 0 ? left.id.localeCompare(right.id) * direction : primary * direction +} + +async function requirePublicWorkspaceDetail(workspaceId: string): Promise { + const workspace = await getPublicWorkspaceDetail(workspaceId) + if (!workspace) throw new Error(`Accessible workspace ${workspaceId} disappeared during listing`) + return workspace +} + +export const listPublicWorkspaces: OperationUseCase< + typeof workspaceOperations.listPublic, + ListPublicWorkspacesInput, + ListPublicWorkspacesResult +> = { + operation: workspaceOperations.listPublic, + async execute({ principal, input }) { + requireAllowedWorkspacePrincipal(principal, workspaceOperations.listPublic) + + if (principal.kind === 'workspace_api_key') { + const context = await loadActiveWorkspaceApplicationContext(principal.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, workspaceOperations.listPublic, context) + const workspace = await requirePublicWorkspaceDetail(context.workspaceId) + return { + workspaces: input.offset === 0 ? [workspace] : [], + hasMore: false, + offset: input.offset, + limit: input.limit, + } + } + + const accessible = await listAccessibleWorkspaceRowsForUser(principal.userId, 'active') + const sorted = accessible + .filter(({ workspace }) => workspace.allowPersonalApiKeys) + .map(({ workspace }) => workspace) + .sort((left, right) => compareWorkspaceRows(left, right, input.sortBy, input.sortOrder)) + const page = sorted.slice(input.offset, input.offset + input.limit) + const workspaces = await Promise.all(page.map(({ id }) => requirePublicWorkspaceDetail(id))) + + return { + workspaces, + hasMore: sorted.length > input.offset + input.limit, + offset: input.offset, + limit: input.limit, + } + }, +} diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index 7109d87f02a..e9db632e151 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -3,6 +3,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const export const workspaceOperations = { + listPublic: defineWorkspaceOperation({ + id: 'workspaces.list_public', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), readPublicDetail: defineWorkspaceOperation({ id: 'workspaces.read_public_detail', minimumRole: 'read', diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index 842e8022f58..6180bb5b973 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -137,9 +137,11 @@ describe('credential connection commands', () => { type: 'service_account', providerId: 'zoom-service-account', displayName: 'Production Zoom', - clientId: 'client', - clientSecret: 'secret', - orgId: 'account', + credentials: JSON.stringify({ + clientId: 'client', + clientSecret: 'secret', + orgId: 'account', + }), }, }) }) diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index a00d1c81b69..39a1cf5f2cf 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -119,7 +119,7 @@ async function createServiceAccount( throw new SimApiError(`--id is required for ${providerId}.`, 0) } - const credentials = credentialValues(provider, options.credentials) + const credentialFields = credentialValues(provider, options.credentials) const operation = V2_OPERATIONS.createServiceAccountCredential const response = await client.request(operation.path, { method: operation.method, @@ -130,7 +130,7 @@ async function createServiceAccount( displayName: options.name, ...(options.description ? { description: options.description } : {}), ...(options.id ? { id: options.id } : {}), - ...credentials, + credentials: JSON.stringify(credentialFields), }, }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c90fd06067e..9799bb7a68a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -139,6 +139,15 @@ export const CLI_CONTRACT: CliContract = { selectAll: { boolean: true, describe: 'Apply to every document in the knowledge base' }, }, }, + listKnowledgeConnectorDocuments: { + command: 'knowledge connectors documents list', + }, + updateKnowledgeConnectorDocuments: { + command: 'knowledge connectors documents update', + flags: { + documentIds: { name: 'document', list: true }, + }, + }, // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. undeployWorkflow: { command: 'workflows undeploy', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 62bec70c50e..b2fc05b9e3e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -910,6 +910,44 @@ export type CreateKnowledgeBaseResponse = { data: CreateKnowledgeBaseResponseRef1 } +/** `POST /api/v2/knowledge/[id]/connectors` */ +export type CreateKnowledgeConnectorParams = { + id: string +} + +export type CreateKnowledgeConnectorQuery = Record + +export type CreateKnowledgeConnectorBody = { + workspaceId: string + connectorType: string + credentialId?: string + apiKey?: string + sourceConfig: Record + syncIntervalMinutes?: number +} + +type CreateKnowledgeConnectorResponseRef0 = { + id: string + knowledgeBaseId: string + connectorType: string + credentialId: string | null + sourceConfig: Record + syncMode: string + syncIntervalMinutes: number + status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled' + lastSyncAt: string | null + lastSyncError: string | null + lastSyncDocCount: number | null + nextSyncAt: string | null + consecutiveFailures: number + createdAt: string + updatedAt: string +} + +export type CreateKnowledgeConnectorResponse = { + data: CreateKnowledgeConnectorResponseRef0 +} + /** `POST /api/v2/knowledge/[id]/documents/uploads` */ export type CreateKnowledgeDocumentUploadParams = { id: string @@ -1106,19 +1144,7 @@ export type CreateServiceAccountCredentialBody = { displayName?: string description?: string id?: string - serviceAccountJson?: string - apiToken?: string - domain?: string - signingSecret?: string - botToken?: string - clientId?: string - clientSecret?: string - certificateId?: string - orgId?: string - dataCenter?: string - authMethod?: string - privateKey?: string - username?: string + credentials: string } type CreateServiceAccountCredentialResponseRef0 = { @@ -1787,6 +1813,28 @@ export type DeleteKnowledgeBaseResponse = { data: DeleteKnowledgeBaseResponseRef0 } +/** `DELETE /api/v2/knowledge/[id]/connectors/[connectorId]` */ +export type DeleteKnowledgeConnectorParams = { + id: string + connectorId: string +} + +export type DeleteKnowledgeConnectorQuery = { + workspaceId: string + deleteDocuments?: boolean +} + +type DeleteKnowledgeConnectorResponseRef0 = { + id: string + deleted: true + documentsDeleted: number + documentsKept: number +} + +export type DeleteKnowledgeConnectorResponse = { + data: DeleteKnowledgeConnectorResponseRef0 +} + /** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ export type DeleteKnowledgeDocumentParams = { id: string @@ -2637,6 +2685,53 @@ export type GetKnowledgeBaseResponse = { data: GetKnowledgeBaseResponseRef1 } +/** `GET /api/v2/knowledge/[id]/connectors/[connectorId]` */ +export type GetKnowledgeConnectorParams = { + id: string + connectorId: string +} + +export type GetKnowledgeConnectorQuery = { + workspaceId: string +} + +type GetKnowledgeConnectorResponseRef0 = { + id: string + connectorId: string + status: string + startedAt: string + completedAt: string | null + docsAdded: number + docsUpdated: number + docsDeleted: number + docsUnchanged: number + docsFailed: number + errorMessage: string | null +} + +type GetKnowledgeConnectorResponseRef1 = { + id: string + knowledgeBaseId: string + connectorType: string + credentialId: string | null + sourceConfig: Record + syncMode: string + syncIntervalMinutes: number + status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled' + lastSyncAt: string | null + lastSyncError: string | null + lastSyncDocCount: number | null + nextSyncAt: string | null + consecutiveFailures: number + createdAt: string + updatedAt: string + syncLogs: Array +} + +export type GetKnowledgeConnectorResponse = { + data: GetKnowledgeConnectorResponseRef1 +} + /** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ export type GetKnowledgeDocumentParams = { id: string @@ -3206,6 +3301,29 @@ export type GetWorkspaceResponse = { data: GetWorkspaceResponseRef0 } +/** `POST /api/v2/skills/[id]/editors` */ +export type GrantSkillEditorParams = { + id: string +} + +export type GrantSkillEditorQuery = Record + +export type GrantSkillEditorBody = { + workspaceId: string + email: string +} + +type GrantSkillEditorResponseRef0 = { + email: string + name: string | null + image: string | null + isWorkspaceAdmin: boolean +} + +export type GrantSkillEditorResponse = { + data: GrantSkillEditorResponseRef0 +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowQuery = Record @@ -3547,6 +3665,71 @@ export type ListKnowledgeBasesResponse = { nextCursor: string | null } +/** `GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents` */ +export type ListKnowledgeConnectorDocumentsParams = { + id: string + connectorId: string +} + +export type ListKnowledgeConnectorDocumentsQuery = { + workspaceId: string + includeExcluded?: boolean + limit?: number + cursor?: string +} + +type ListKnowledgeConnectorDocumentsResponseRef0 = { + id: string + filename: string + externalId: string | null + sourceUrl: string | null + enabled: boolean + userExcluded: boolean + createdAt: string + processingStatus: string +} + +export type ListKnowledgeConnectorDocumentsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/connectors` */ +export type ListKnowledgeConnectorsParams = { + id: string +} + +export type ListKnowledgeConnectorsQuery = { + workspaceId: string + sortBy?: 'connectorType' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListKnowledgeConnectorsResponseRef0 = { + id: string + knowledgeBaseId: string + connectorType: string + credentialId: string | null + sourceConfig: Record + syncMode: string + syncIntervalMinutes: number + status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled' + lastSyncAt: string | null + lastSyncError: string | null + lastSyncDocCount: number | null + nextSyncAt: string | null + consecutiveFailures: number + createdAt: string + updatedAt: string +} + +export type ListKnowledgeConnectorsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/knowledge/[id]/documents` */ export type ListKnowledgeDocumentsParams = { id: string @@ -3818,6 +4001,31 @@ export type ListSecretsResponse = { nextCursor: string | null } +/** `GET /api/v2/skills/[id]/editors` */ +export type ListSkillEditorsParams = { + id: string +} + +export type ListSkillEditorsQuery = { + workspaceId: string + sortBy?: 'email' | 'name' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSkillEditorsResponseRef0 = { + email: string + name: string | null + image: string | null + isWorkspaceAdmin: boolean +} + +export type ListSkillEditorsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/skills` */ export type ListSkillsQuery = { workspaceId: string @@ -4167,6 +4375,29 @@ export type ListWorkspaceMembersResponse = { nextCursor: string | null } +/** `GET /api/v2/workspaces` */ +export type ListWorkspacesQuery = { + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListWorkspacesResponseRef0 = { + id: string + name: string + color: string + logoUrl: string | null + memberCount: number + createdAt: string + updatedAt: string +} + +export type ListWorkspacesResponse = { + data: Array + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsQuery = Record @@ -4567,6 +4798,25 @@ export type ResumeWorkflowResponse = data: ResumeWorkflowResponseRef2 } +/** `DELETE /api/v2/skills/[id]/editors` */ +export type RevokeSkillEditorParams = { + id: string +} + +export type RevokeSkillEditorQuery = { + workspaceId: string + email: string +} + +type RevokeSkillEditorResponseRef0 = { + email: string + revoked: true +} + +export type RevokeSkillEditorResponse = { + data: RevokeSkillEditorResponseRef0 +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -4820,6 +5070,28 @@ export type SetSecretResponse = { data: SetSecretResponseRef0 } +/** `POST /api/v2/knowledge/[id]/connectors/[connectorId]/sync` */ +export type SyncKnowledgeConnectorParams = { + id: string + connectorId: string +} + +export type SyncKnowledgeConnectorQuery = Record + +export type SyncKnowledgeConnectorBody = { + workspaceId: string + rehydrate?: boolean +} + +type SyncKnowledgeConnectorResponseRef0 = { + id: string + syncTriggered: true +} + +export type SyncKnowledgeConnectorResponse = { + data: SyncKnowledgeConnectorResponseRef0 +} + /** `GET /api/v2/tables/exports/[exportId]/download` */ export type TableExportDownloadParams = { exportId: string @@ -5025,6 +5297,67 @@ export type UpdateKnowledgeBaseResponse = { data: UpdateKnowledgeBaseResponseRef1 } +/** `PATCH /api/v2/knowledge/[id]/connectors/[connectorId]` */ +export type UpdateKnowledgeConnectorParams = { + id: string + connectorId: string +} + +export type UpdateKnowledgeConnectorQuery = Record + +export type UpdateKnowledgeConnectorBody = { + workspaceId: string + sourceConfig?: Record + syncIntervalMinutes?: number + status?: 'active' | 'paused' +} + +type UpdateKnowledgeConnectorResponseRef0 = { + id: string + knowledgeBaseId: string + connectorType: string + credentialId: string | null + sourceConfig: Record + syncMode: string + syncIntervalMinutes: number + status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled' + lastSyncAt: string | null + lastSyncError: string | null + lastSyncDocCount: number | null + nextSyncAt: string | null + consecutiveFailures: number + createdAt: string + updatedAt: string +} + +export type UpdateKnowledgeConnectorResponse = { + data: UpdateKnowledgeConnectorResponseRef0 +} + +/** `PATCH /api/v2/knowledge/[id]/connectors/[connectorId]/documents` */ +export type UpdateKnowledgeConnectorDocumentsParams = { + id: string + connectorId: string +} + +export type UpdateKnowledgeConnectorDocumentsQuery = Record + +export type UpdateKnowledgeConnectorDocumentsBody = { + workspaceId: string + operation: 'restore' | 'exclude' + documentIds: Array +} + +type UpdateKnowledgeConnectorDocumentsResponseRef0 = { + operation: 'restore' | 'exclude' + updatedCount: number + documentIds: Array +} + +export type UpdateKnowledgeConnectorDocumentsResponse = { + data: UpdateKnowledgeConnectorDocumentsResponseRef0 +} + /** `PATCH /api/v2/knowledge/[id]/documents/[documentId]` */ export type UpdateKnowledgeDocumentParams = { id: string @@ -6146,6 +6479,40 @@ export const V2_OPERATIONS = { }, }, }, + createKnowledgeConnector: { + method: 'POST', + path: '/api/v2/knowledge/[id]/connectors', + pathParams: ['id'] as const, + pathParamDocs: { id: 'Unique knowledge base identifier.' }, + responseMode: 'json', + summary: 'Create Knowledge Connector', + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + connectorType: { kind: 'string', required: true, describe: 'Registered connector type.' }, + credentialId: { + kind: 'string', + describe: 'OAuth credential identifier for connectors that require OAuth.', + }, + apiKey: { + kind: 'string', + describe: 'Write-only API key for connectors that use API-key authentication.', + }, + sourceConfig: { + kind: 'object', + required: true, + describe: 'Connector-specific source selection and filtering configuration.', + }, + syncIntervalMinutes: { + kind: 'integer', + default: 1440, + describe: 'Scheduled synchronization interval in minutes; zero disables scheduling.', + }, + }, + }, createKnowledgeDocumentUpload: { method: 'POST', path: '/api/v2/knowledge/[id]/documents/uploads', @@ -6318,22 +6685,12 @@ export const V2_OPERATIONS = { kind: 'string', describe: 'Required only when provider discovery requests a client-generated ID.', }, - serviceAccountJson: { + credentials: { kind: 'string', - describe: 'Write-only Google service-account JSON key.', + required: true, + describe: + 'Write-only JSON object string containing the fields declared by credential-provider discovery.', }, - apiToken: { kind: 'string', describe: 'Write-only provider API token.' }, - domain: { kind: 'string', describe: 'Provider account domain.' }, - signingSecret: { kind: 'string', describe: 'Write-only webhook signing secret.' }, - botToken: { kind: 'string', describe: 'Write-only bot token.' }, - clientId: { kind: 'string', describe: 'OAuth client identifier.' }, - clientSecret: { kind: 'string', describe: 'Write-only OAuth client secret.' }, - certificateId: { kind: 'string', describe: 'Provider certificate mapping identifier.' }, - orgId: { kind: 'string', describe: 'Provider organization ID.' }, - dataCenter: { kind: 'string', describe: 'Provider data center.' }, - authMethod: { kind: 'string', describe: 'Provider authentication method.' }, - privateKey: { kind: 'string', describe: 'Write-only PEM private key.' }, - username: { kind: 'string', describe: 'Provider run-as username.' }, }, }, createSkill: { @@ -6604,6 +6961,28 @@ export const V2_OPERATIONS = { }, }, }, + deleteKnowledgeConnector: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'Delete Knowledge Connector', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + deleteDocuments: { + kind: 'boolean', + describe: 'Also permanently delete documents produced by this connector.', + }, + }, + }, deleteKnowledgeDocument: { method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', @@ -7046,6 +7425,24 @@ export const V2_OPERATIONS = { }, }, }, + getKnowledgeConnector: { + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'Get Knowledge Connector', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + }, + }, getKnowledgeDocument: { method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', @@ -7215,6 +7612,24 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workspace', }, + grantSkillEditor: { + method: 'POST', + path: '/api/v2/skills/[id]/editors', + pathParams: ['id'] as const, + pathParamDocs: { + id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.', + }, + responseMode: 'json', + summary: 'Grant Skill Editor', + body: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, + email: { + kind: 'string', + required: true, + describe: 'Email address of a current workspace member.', + }, + }, + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -7610,6 +8025,77 @@ export const V2_OPERATIONS = { }, }, }, + listKnowledgeConnectorDocuments: { + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'List Knowledge Connector Documents', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + includeExcluded: { + kind: 'boolean', + describe: 'Include documents explicitly excluded by a user.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum connector documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, + listKnowledgeConnectors: { + method: 'GET', + path: '/api/v2/knowledge/[id]/connectors', + pathParams: ['id'] as const, + pathParamDocs: { id: 'Unique knowledge base identifier.' }, + responseMode: 'json', + summary: 'List Knowledge Connectors', + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + sortBy: { + kind: 'enum', + values: ['connectorType', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum connectors to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, listKnowledgeDocuments: { method: 'GET', path: '/api/v2/knowledge/[id]/documents', @@ -7931,6 +8417,43 @@ export const V2_OPERATIONS = { }, }, }, + listSkillEditors: { + method: 'GET', + path: '/api/v2/skills/[id]/editors', + pathParams: ['id'] as const, + pathParamDocs: { + id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.', + }, + responseMode: 'json', + summary: 'List Skill Editors', + query: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, + sortBy: { + kind: 'enum', + values: ['email', 'name'] as const, + default: 'email', + describe: + 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, listSkills: { method: 'GET', path: '/api/v2/skills', @@ -8267,6 +8790,39 @@ export const V2_OPERATIONS = { }, }, }, + listWorkspaces: { + method: 'GET', + path: '/api/v2/workspaces', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workspaces', + query: { + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + describe: + 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, moveFileItems: { method: 'POST', path: '/api/v2/files/move', @@ -8427,6 +8983,24 @@ export const V2_OPERATIONS = { input: { kind: 'unknown', describe: 'Input supplied to the paused workflow block.' }, }, }, + revokeSkillEditor: { + method: 'DELETE', + path: '/api/v2/skills/[id]/editors', + pathParams: ['id'] as const, + pathParamDocs: { + id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.', + }, + responseMode: 'json', + summary: 'Revoke Skill Editor', + query: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, + email: { + kind: 'string', + required: true, + describe: 'Email address of a current workspace member.', + }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -8577,6 +9151,29 @@ export const V2_OPERATIONS = { }, }, }, + syncKnowledgeConnector: { + method: 'POST', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/sync', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'Sync Knowledge Connector', + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + rehydrate: { + kind: 'boolean', + default: false, + describe: 'Re-fetch and re-index every existing connector document.', + }, + }, + }, tableExportDownload: { method: 'GET', path: '/api/v2/tables/exports/[exportId]/download', @@ -8660,6 +9257,66 @@ export const V2_OPERATIONS = { folderPath: { kind: 'string', describe: 'New containing-folder path.' }, }, }, + updateKnowledgeConnector: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'Update Knowledge Connector', + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + sourceConfig: { + kind: 'object', + describe: 'Replacement source selection and filtering configuration.', + }, + syncIntervalMinutes: { + kind: 'integer', + describe: 'New scheduled synchronization interval in minutes.', + }, + status: { + kind: 'enum', + values: ['active', 'paused'] as const, + describe: 'New connector state.', + }, + }, + }, + updateKnowledgeConnectorDocuments: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents', + pathParams: ['id', 'connectorId'] as const, + pathParamDocs: { + id: 'Knowledge base that owns the connector.', + connectorId: 'Connector selected for the operation.', + }, + responseMode: 'json', + summary: 'Update Knowledge Connector Documents', + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that owns the knowledge base.', + }, + operation: { + kind: 'enum', + required: true, + values: ['restore', 'exclude'] as const, + describe: 'Whether to restore or exclude the selected documents.', + }, + documentIds: { + kind: 'array', + required: true, + describe: 'Connector document identifiers to update.', + }, + }, + }, updateKnowledgeDocument: { method: 'PATCH', path: '/api/v2/knowledge/[id]/documents/[documentId]', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6b41d63c675..4d2e57bbfcd 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: 1132, - zodRoutes: 1132, + totalRoutes: 1138, + zodRoutes: 1138, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 2fb9c71e064..b318b279e16 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -35,9 +35,9 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-logs.json', 2], ['apps/docs/openapi-v2-files-audit.json', 22], ['apps/docs/openapi-v2-tables.json', 44], - ['apps/docs/openapi-v2-knowledge.json', 21], + ['apps/docs/openapi-v2-knowledge.json', 29], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 26], + ['apps/docs/openapi-v2-resources.json', 30], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(139) + expect(totalOperations).toBe(151) }) it('documents mixed workflow execution and resume responses', () => { From a3ae61ed2e16958f63ade10d85a6f3be0f5df356 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 20 Aug 2026 14:20:32 -0700 Subject: [PATCH 2/4] fix(cli): gate destructive v2 commands --- apps/docs/content/docs/en/cli/knowledge.mdx | 1 + apps/docs/content/docs/en/cli/reference.mdx | 2 ++ apps/docs/content/docs/en/cli/skills.mdx | 1 + packages/sim-cli/src/contract/commands.ts | 7 +++++++ 4 files changed, 11 insertions(+) diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index eeb96dc129e..a2fb532b6d8 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -268,6 +268,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index c29a1c5c057..19e6580b832 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -1232,6 +1232,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | +| `-y, --yes` | Yes | Confirm this destructive operation. | @@ -2202,6 +2203,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index f63ee7175da..c261f689d5b 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -146,6 +146,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | +| `-y, --yes` | Yes | Confirm this destructive operation. | diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 9799bb7a68a..04ac2378b94 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -176,11 +176,18 @@ export const CLI_CONTRACT: CliContract = { pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT, confirm: 'This deletes the document and its embeddings.', }, + deleteKnowledgeConnector: { + confirm: + 'This deletes the connector; --delete-documents also deletes its synchronized documents.', + }, deleteFile: { confirm: 'This archives the file.' }, deleteCredential: { confirm: 'This disconnects the credential and removes its stored authentication.', }, deleteSkill: { confirm: 'This deletes the skill.' }, + revokeSkillEditor: { + confirm: 'This revokes the explicit skill editor grant for the selected email.', + }, deleteCustomTool: { confirm: 'This deletes the custom tool.' }, deleteMcpServer: { confirm: 'This removes the MCP server and the tools it provides.', From 45006ab145dca2da8345bd5a3c2caa6c006fddaa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 20 Aug 2026 14:29:59 -0700 Subject: [PATCH 3/4] fix(test): update v2 request-slice count --- .../sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index c80d5e66491..9d1a27be644 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -182,10 +182,11 @@ describe('knowledge and files request-slice strictness', () => { * A count, so a document that stopped listing its routes cannot make every * assertion below pass vacuously. It rises when a route gains a slice: it went * 45 → 63 when the knowledge and files endpoints that take no query params - * started saying so with `noInputSchema` instead of omitting `query`. + * started saying so with `noInputSchema` instead of omitting `query`, then + * 63 → 75 when knowledge connector management joined the documented surface. */ it('sweeps every documented query and body slice', () => { - expect(slices.length).toBe(63) + expect(slices.length).toBe(75) }) it.each(slices)('%s rejects an undeclared key', (_name, schema) => { From f0b8e0ba76732a078f6afb024b622c76777da55a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 20 Aug 2026 15:32:15 -0700 Subject: [PATCH 4/4] feat(cli): add shared workspace profiles --- .../content/docs/en/cli/authentication.mdx | 34 +- apps/docs/content/docs/en/cli/commands.mdx | 9 +- .../content/docs/en/cli/configuration.mdx | 35 +- apps/docs/content/docs/en/cli/meta.json | 1 + apps/docs/content/docs/en/cli/profiles.mdx | 42 +++ apps/docs/content/docs/en/cli/reference.mdx | 50 ++- .../lib/copilot/generated/docs-manifest.ts | 1 + packages/sim-cli/README.md | 23 +- packages/sim-cli/src/commands/auth.test.ts | 304 +++++++++++++++++- packages/sim-cli/src/commands/auth.ts | 214 ++++++++++-- .../sim-cli/src/commands/configure.test.ts | 22 +- packages/sim-cli/src/commands/configure.ts | 21 +- packages/sim-cli/src/config/index.ts | 2 + packages/sim-cli/src/config/profile.test.ts | 94 ++++++ packages/sim-cli/src/config/profile.ts | 62 +++- packages/sim-cli/src/program.ts | 1 + 16 files changed, 855 insertions(+), 60 deletions(-) create mode 100644 apps/docs/content/docs/en/cli/profiles.mdx diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 617015ef35c..40cdf96e307 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -53,6 +53,20 @@ sim workflows list --workspace ws_other `sim login --workspace ` preselects a workspace in the picker, and re-logging into an existing profile preselects the one already configured. +To save another workspace without minting or copying another personal key, add +a workspace profile: + +```bash +sim workspaces list +sim profile add acme --workspace ws_acme +sim --profile acme whoami +``` + +The new profile stores `auth_profile = default` and its own workspace. Omit +`--workspace` in an interactive terminal to choose from the workspaces the +active key can access; scripts must provide the workspace ID explicitly. The +picker is capped at 1,000 entries and asks for an explicit ID above that. + ## Checking who you are ```bash @@ -76,6 +90,12 @@ sim logout # remove the stored key sim logout --all # remove the profile entirely, including its settings ``` +A workspace profile that shares authentication cannot remove the shared key. +Remove only that local profile with `sim logout --all --profile `, or log +out of the authentication profile named by the error message. Removing an +authentication profile entirely is refused until its workspace profiles are +removed, so it cannot leave dangling references. + `sim logout` removes the key from disk but does **not** revoke it. Revoke keys in Sim under **Settings → API keys**. @@ -116,9 +136,9 @@ jobs: SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }} ``` -## Several accounts at once +## Several accounts and workspaces -Each profile holds one identity and one set of defaults: +Use separate logins for separate identities or deployments: ```bash sim login --profile dev --endpoint http://localhost:3000 @@ -128,6 +148,16 @@ sim workflows list --profile dev sim workflows list --profile prod ``` +Use workspace profiles when one personal key should target several workspaces: + +```bash +sim profile add marketing --workspace ws_marketing +sim profile add support --workspace ws_support + +sim workflows list --profile marketing +sim workflows list --profile support +``` + See [Configuration](/cli/configuration) for how profiles are stored and resolved. ## Self-hosted and non-production deployments diff --git a/apps/docs/content/docs/en/cli/commands.mdx b/apps/docs/content/docs/en/cli/commands.mdx index cbe18f15bbf..67c44e3f197 100644 --- a/apps/docs/content/docs/en/cli/commands.mdx +++ b/apps/docs/content/docs/en/cli/commands.mdx @@ -30,6 +30,7 @@ These apply to every command, and may be written before or after it. | Group | Description | | --- | --- | +| [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login | | [`sim audit-logs`](/cli/audit-logs) | Manage audit logs | | [`sim billing`](/cli/billing) | Manage billing | | [`sim credentials`](/cli/credentials) | Manage credentials | @@ -94,14 +95,6 @@ sim whoami [options] -## List the profiles defined in the config and credentials files - -```bash -sim profiles -``` - -Also available as `sim profile`. - ## Set a profile's endpoint, default workspace, or output format ```bash diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index a24bae70a1b..b0b177f90f9 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -9,8 +9,10 @@ for a single command. ## Profiles -A profile is one identity plus one set of defaults, in the style of the AWS CLI. -Select one with `-P`, `--profile`, or `SIM_PROFILE`: +A profile selects one set of defaults, in the style of the AWS CLI. It normally +uses its same-named stored identity, but a workspace profile can share another +profile's identity through `auth_profile`. Select one with `-P`, `--profile`, or +`SIM_PROFILE`: ```bash sim workflows list --profile dev @@ -23,6 +25,12 @@ The profile is named `default` when you do not pick one. sim profiles # list them; * marks the active one ``` +Add a profile for another workspace without creating or copying an API key: + +```bash +sim profile add acme --workspace ws_acme +``` + ## Setting defaults ```bash @@ -51,7 +59,7 @@ Each setting resolves independently, and the first match wins: | --- | --- | | 1 | Command-line flag — `--endpoint`, `--workspace`, `--output` | | 2 | Environment — `SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT` | -| 3 | `~/.sim/config` and `~/.sim/credentials`, for the selected profile | +| 3 | `~/.sim/config` for the selected profile and `~/.sim/credentials` for its `auth_profile`, when set | | 4 | Built-in default — `https://www.sim.ai` and `table` | `sim whoami` prints the winning source for each setting: @@ -74,6 +82,10 @@ output = table [profile dev] endpoint = http://localhost:3000 workspace = ws_local + +[profile acme] +auth_profile = default +workspace = ws_acme ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -89,6 +101,10 @@ api_key = sim_… Section naming follows the AWS convention: `[profile dev]` in config, `[dev]` in credentials. The `default` profile is `[default]` in both. +`auth_profile` references one direct profile and shares only its endpoint and +API key; workspace and output remain local. References cannot be chained, and a +shared profile cannot also set its own endpoint or API key. + ## Environment variables | Variable | Effect | @@ -121,6 +137,19 @@ sim configure --set-workspace ws_abc123 export SIM_WORKSPACE=ws_abc123 ``` +For a reusable selection, create a workspace profile backed by the current +stored login: + +```bash +sim workspaces list +sim profile add acme --workspace ws_acme +sim --profile acme tables list +``` + +When `--workspace` is omitted in a terminal, `profile add` presents an +interactive picker, capped at 1,000 entries. It refuses environment-only keys +and endpoint overrides because those values would disappear in another shell. + `sim billing status`, `sim billing logs`, and `sim audit-logs list` accept `--all-workspaces` to drop the filter instead. It cannot be combined with `--workspace`. diff --git a/apps/docs/content/docs/en/cli/meta.json b/apps/docs/content/docs/en/cli/meta.json index a18d504f907..5e3ceb688a5 100644 --- a/apps/docs/content/docs/en/cli/meta.json +++ b/apps/docs/content/docs/en/cli/meta.json @@ -11,6 +11,7 @@ "troubleshooting", "---Commands---", "commands", + "profiles", "audit-logs", "billing", "credentials", diff --git a/apps/docs/content/docs/en/cli/profiles.mdx b/apps/docs/content/docs/en/cli/profiles.mdx new file mode 100644 index 00000000000..5cdb26f604b --- /dev/null +++ b/apps/docs/content/docs/en/cli/profiles.mdx @@ -0,0 +1,42 @@ +--- +title: Profiles +description: List profiles or add a workspace profile that shares a stored login — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +`sim profiles` is also spelled `sim profile`. + +Every command below also accepts the [global options](/cli/commands#global-options). + +## List configured profiles + +```bash +sim profiles list +``` + +## Add a workspace profile that shares the active stored login + +```bash +sim profiles add [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `name` | Yes | Name for the new profile | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-w, --workspace ` | No | Existing workspace to use; omit for an interactive picker. | + + diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 19e6580b832..5a37dbaedaf 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -80,16 +80,6 @@ sim whoami [options] -## sim profiles - -List the profiles defined in the config and credentials files - -```bash -sim profiles -``` - -Also available as `sim profile`. - ## sim configure Set a profile's endpoint, default workspace, or output format @@ -111,6 +101,46 @@ sim configure [options] +## sim profiles + +Also spelled `sim profile`. + +### sim profiles list + +List configured profiles + +```bash +sim profiles list +``` + +### sim profiles add + +Add a workspace profile that shares the active stored login + +```bash +sim profiles add [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `name` | Yes | Name for the new profile | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-w, --workspace ` | No | Existing workspace to use; omit for an interactive picker. | + + + ## sim audit-logs Also spelled `sim audit-log`. diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 6adc3e3217e..79af23f4572 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -35,6 +35,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/logs.mdx', 'cli/mcp-servers.mdx', 'cli/output.mdx', + 'cli/profiles.mdx', 'cli/reference.mdx', 'cli/scripting.mdx', 'cli/secrets.mdx', diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 6f308483843..14ecfd4f630 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -12,9 +12,9 @@ Full documentation: **https://docs.sim.ai/cli** ## Profiles -Profiles work like the AWS CLI: one identity and one set of defaults per named -profile, selected with `-P`, `--profile`, or `SIM_PROFILE`. This is what lets you keep -production and a local dev stack side by side without re-authenticating. +Profiles work like the AWS CLI and are selected with `-P`, `--profile`, or +`SIM_PROFILE`. A profile normally owns one identity and one set of defaults; a +workspace profile can instead share a stored identity through `auth_profile`. Non-secret settings live in `~/.sim/config`: @@ -27,6 +27,10 @@ output = table [profile dev] endpoint = http://localhost:3000 workspace = ws_local + +[profile acme] +auth_profile = default +workspace = ws_acme ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -45,7 +49,8 @@ The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentia ```bash sim configure --set-endpoint http://localhost:3000 --profile dev sim configure --set-workspace ws_local --profile dev -sim profiles # list them; * marks the active one +sim profiles # list them; * marks the active one +sim profile add acme --workspace ws_acme # share the active stored login sim whoami # resolved values, where each came from, and whether they work ``` @@ -62,7 +67,7 @@ Each setting resolves independently, first match wins: indefinitely) and `SIM_DEBUG=1` traces requests to stderr. Node ignores `HTTPS_PROXY` unless `NODE_USE_ENV_PROXY=1` is also set, on Node 22.21+ or 24.5+; the CLI warns when a proxy is configured but will not be used. -| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | +| 3 | `~/.sim/config` for the selected profile and credentials for its `auth_profile`, when set | | 4 | Built-in default (`https://www.sim.ai`, `table`) | Formats are listed under [Output formats](#output-formats). @@ -112,8 +117,11 @@ the key can access. `sim login --workspace ` preselects a workspace in the picker, and an existing profile's workspace preselects itself on re-login. -`sim logout` removes the stored key. It does not revoke it — do that in -Settings → API keys. +`sim logout` removes the stored key. A shared workspace profile cannot remove +its authentication profile's key; use `sim logout --all --profile ` to +remove only the workspace profile. An authentication profile cannot be removed +entirely while workspace profiles reference it. Logging out does not revoke a +key — do that in Settings → API keys. ## Commands @@ -147,6 +155,7 @@ sim logs get sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization +sim workspaces list sim workspaces get sim workspaces members diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index a7028c139f4..d6e3f7dc872 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -5,9 +5,12 @@ const mocks = vi.hoisted(() => ({ buildApprovalUrl: vi.fn(() => 'https://sim.ai/cli/auth?code=ABCD'), createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })), createInterface: vi.fn(), + deleteProfile: vi.fn(() => ({ config: false, credentials: false })), + listAuthenticationDependents: vi.fn<() => string[]>(() => []), listProfiles: vi.fn<() => string[]>(() => []), request: vi.fn(), readCredentialsProfile: vi.fn<() => Record>(() => ({})), + resolveAuthenticationProfileName: vi.fn((profile: string) => profile), pollForKey: vi.fn(async () => ({ apiKey: 'sim-key', scope: 'platform' as const, @@ -38,20 +41,25 @@ vi.mock('../auth/device-flow', () => ({ pollForKey: mocks.pollForKey, })) vi.mock('../config/index', () => ({ + configPath: () => '/tmp/sim-config', credentialsPath: () => '/tmp/sim-credentials', - deleteProfile: vi.fn(), + DEFAULT_PROFILE: 'default', + deleteProfile: mocks.deleteProfile, + listAuthenticationDependents: mocks.listAuthenticationDependents, listProfiles: mocks.listProfiles, readCredentialsProfile: mocks.readCredentialsProfile, + resolveAuthenticationProfileName: mocks.resolveAuthenticationProfileName, writeConfigProfile: mocks.writeConfigProfile, writeCredentialsProfile: mocks.writeCredentialsProfile, })) vi.mock('../context', () => ({ + globalsOf: (command: Command) => command.optsWithGlobals(), profileFrom: mocks.profileFrom, clientFrom: () => ({ client: { request: mocks.request }, profile: mocks.profileFrom() }), })) import { SimApiError } from '../http/client' -import { loginCommand, profilesCommand, whoamiCommand } from './auth' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './auth' const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') @@ -71,11 +79,27 @@ async function whoami(...args: string[]): Promise { await root.parseAsync(['node', 'sim', 'whoami', ...args]) } +async function profiles(...args: string[]): Promise { + const root = new Command('sim') + .exitOverride() + .option('-P, --profile ') + .option('-w, --workspace ') + root.addCommand(profilesCommand()) + await root.parseAsync(['node', 'sim', 'profiles', ...args]) +} + +async function logout(...args: string[]): Promise { + const root = new Command('sim').exitOverride().option('-P, --profile ') + root.addCommand(logoutCommand()) + await root.parseAsync(['node', 'sim', 'logout', ...args]) +} + describe('login command', () => { beforeEach(() => { vi.clearAllMocks() mocks.listProfiles.mockReturnValue([]) mocks.readCredentialsProfile.mockReturnValue({}) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) mocks.profileFrom.mockReturnValue({ name: 'default', endpoint: 'https://sim.ai', @@ -115,6 +139,29 @@ describe('login command', () => { expect(mocks.createAuthRequest).toHaveBeenCalledOnce() }) + it('refuses to replace authentication through a shared workspace profile', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'acme', + endpoint: 'https://sim.ai', + apiKey: 'sim-key', + workspaceId: 'ws_acme', + output: 'table', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.resolveAuthenticationProfileName.mockReturnValue('default') + + await expect(login()).rejects.toThrow( + 'Profile "acme" shares authentication with "default". Run: sim login --profile default' + ) + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + }) + it('requires --yes before overwriting non-interactively', async () => { setInteractive(false) mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) @@ -203,9 +250,262 @@ describe('login command', () => { }) describe('profiles command', () => { + beforeEach(() => { + vi.clearAllMocks() + setInteractive(false) + mocks.listProfiles.mockReturnValue([]) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'stored-key' }) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'stored-key', + workspaceId: 'ws_default', + output: 'table', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.request.mockResolvedValue({ + data: { id: 'ws_acme', name: 'Acme', memberCount: 3 }, + }) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => '1'), + close: vi.fn(), + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY) + else Reflect.deleteProperty(process.stdin, 'isTTY') + }) + it('accepts the singular profile alias', () => { expect(profilesCommand().alias()).toBe('profile') }) + + it('keeps the existing bare profiles command as the list shortcut', async () => { + mocks.listProfiles.mockReturnValue(['default']) + + await profiles() + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('default')) + }) + + it('adds a validated workspace profile using the active stored login', async () => { + await profiles('add', 'acme', '--workspace', 'ws_acme') + + expect(mocks.request).toHaveBeenCalledWith('/api/v2/workspaces/ws_acme', { method: 'GET' }) + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('acme', { + auth_profile: 'default', + workspace: 'ws_acme', + }) + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it('does not write a profile when the active key cannot reach the workspace', async () => { + mocks.request.mockRejectedValue(new SimApiError('Workspace not found', 404)) + + await expect(profiles('add', 'acme', '--workspace', 'ws_missing')).rejects.toThrow( + 'Workspace not found' + ) + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('flattens an active workspace profile to its canonical authentication profile', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'engineering', + endpoint: 'https://sim.ai', + apiKey: 'stored-key', + workspaceId: 'ws_engineering', + output: 'table', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.resolveAuthenticationProfileName.mockReturnValue('corporate') + + await profiles('add', 'finance', '--workspace', 'ws_acme') + + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('finance', { + auth_profile: 'corporate', + workspace: 'ws_acme', + }) + }) + + it('refuses to persist a profile from an environment-only key', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'environment-key', + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'unset', + output: 'default', + }, + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'the active API key is not stored' + ) + expect(mocks.request).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('refuses to persist a profile from an ephemeral endpoint override', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://temporary.example', + apiKey: 'stored-key', + workspaceId: null, + output: 'table', + sources: { + endpoint: 'env', + apiKey: 'credentials', + workspaceId: 'unset', + output: 'default', + }, + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'the active endpoint comes from env' + ) + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('requires an explicit workspace outside an interactive terminal', async () => { + await expect(profiles('add', 'acme')).rejects.toThrow( + 'Pass --workspace when creating a profile non-interactively.' + ) + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('offers every accessible workspace in an interactive picker', async () => { + setInteractive(true) + const question = vi.fn(async () => '2') + const close = vi.fn() + mocks.createInterface.mockReturnValue({ question, close }) + mocks.request.mockResolvedValue({ + data: [ + { id: 'ws_acme', name: 'Acme' }, + { id: 'ws_beta', name: 'Beta' }, + ], + nextCursor: null, + }) + + await profiles('add', 'beta') + + expect(mocks.request).toHaveBeenCalledWith('/api/v2/workspaces', { + method: 'GET', + query: { sortBy: 'name', sortOrder: 'asc', limit: 100, cursor: null }, + }) + expect(question).toHaveBeenCalledWith('Choose a workspace [1-2]: ') + expect(close).toHaveBeenCalledOnce() + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('beta', { + auth_profile: 'default', + workspace: 'ws_beta', + }) + }) + + it('caps the interactive workspace roster before prompting', async () => { + setInteractive(true) + mocks.request.mockResolvedValue({ + data: Array.from({ length: 1001 }, (_, index) => ({ + id: `ws_${index}`, + name: `Workspace ${index}`, + })), + nextCursor: null, + }) + + await expect(profiles('add', 'large')).rejects.toThrow( + 'more than 1000 workspaces, which is too many to show interactively' + ) + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('does not overwrite an existing profile', async () => { + mocks.listProfiles.mockReturnValue(['acme']) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'Profile "acme" already exists.' + ) + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('lists a shared profile as authenticated by its referenced profile', async () => { + mocks.listProfiles.mockReturnValue(['acme', 'default']) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => + profile === 'acme' ? 'default' : profile + ) + + await profiles('list') + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('acme (auth: default)') + expect(output).not.toContain('acme (no key)') + }) +}) + +describe('logout command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listAuthenticationDependents.mockReturnValue([]) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'stored-key' }) + mocks.profileFrom.mockReturnValue({ + name: 'acme', + endpoint: 'https://sim.ai', + apiKey: 'stored-key', + workspaceId: 'ws_acme', + output: 'table', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'default', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('does not remove a key through a shared workspace profile', async () => { + mocks.resolveAuthenticationProfileName.mockReturnValue('default') + + await expect(logout()).rejects.toThrow( + 'Log out of the authentication profile instead: sim logout --profile default' + ) + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it('removes only the selected alias under --all', async () => { + mocks.deleteProfile.mockReturnValue({ config: true, credentials: false }) + + await logout('--all', '--profile', 'acme') + + expect(mocks.deleteProfile).toHaveBeenCalledWith('acme') + expect(mocks.resolveAuthenticationProfileName).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it('refuses to remove an authentication profile while workspace profiles use it', async () => { + mocks.listAuthenticationDependents.mockReturnValue(['acme', 'beta']) + + await expect(logout('--all', '--profile', 'default')).rejects.toThrow( + 'Cannot remove authentication profile "default" because it is used by: acme, beta.' + ) + expect(mocks.deleteProfile).not.toHaveBeenCalled() + }) }) describe('whoami command', () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 71147ddf9b3..1ac824465d7 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -9,20 +9,33 @@ import { pollForKey, } from '../auth/device-flow' import { + configPath, credentialsPath, + DEFAULT_PROFILE, deleteProfile, + listAuthenticationDependents, listProfiles, type ResolvedProfile, readCredentialsProfile, + resolveAuthenticationProfileName, type SettingSource, writeConfigProfile, writeCredentialsProfile, } from '../config/index' -import { clientFrom, profileFrom } from '../context' -import { type GetWorkspaceResponse, V2_OPERATIONS } from '../generated/v2-api' -import { resolvePath, SimApiError, type SimClient } from '../http/client' +import { clientFrom, globalsOf, profileFrom } from '../context' +import { + type GetWorkspaceResponse, + type ListWorkspacesResponse, + V2_OPERATIONS, +} from '../generated/v2-api' +import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client' import { printRecord, safeOneLine } from '../output/render' +type SelectableWorkspace = ListWorkspacesResponse['data'][number] + +const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ +const MAX_INTERACTIVE_WORKSPACES = 1000 + /** * Best-effort browser launch. Failure is not an error: the URL is always printed * first, so a headless box, an SSH session, or a machine with no handler just @@ -88,6 +101,128 @@ async function confirmProfileOverwrite(profileName: string): Promise { } } +function selectedProfileName(command: Command): string { + return globalsOf(command).profile || process.env.SIM_PROFILE || DEFAULT_PROFILE +} + +function validateNewProfileName(profileName: string): void { + if (!PROFILE_NAME_PATTERN.test(profileName)) { + throw new SimApiError( + `Invalid profile name "${profileName}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.`, + 0 + ) + } + if (listProfiles().includes(profileName)) { + throw new SimApiError( + `Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`, + 0 + ) + } +} + +function requireStoredAuthentication(profile: ResolvedProfile): string { + const authProfile = resolveAuthenticationProfileName(profile.name) + const storedKey = readCredentialsProfile(authProfile).api_key + if (profile.sources.apiKey !== 'credentials' || !storedKey) { + throw new SimApiError( + `Cannot create a shared profile from "${profile.name}": the active API key is not stored. Run: sim login --profile ${authProfile}`, + 0 + ) + } + if (profile.sources.endpoint === 'flag' || profile.sources.endpoint === 'env') { + throw new SimApiError( + `Cannot create a shared profile from "${profile.name}": the active endpoint comes from ${profile.sources.endpoint}. Save it with: sim configure --profile ${authProfile} --set-endpoint ${profile.endpoint}`, + 0 + ) + } + return authProfile +} + +async function getWorkspaceById( + client: Pick, + workspaceId: string +): Promise { + const operation = V2_OPERATIONS.getWorkspace + const response = await client.request( + resolvePath(operation.path, { workspaceId }), + { method: operation.method } + ) + return response.data +} + +async function chooseWorkspace(client: Pick): Promise { + if (!process.stdin.isTTY) { + throw new SimApiError( + 'No workspace provided. Pass --workspace when creating a profile non-interactively.', + 0 + ) + } + + const operation = V2_OPERATIONS.listWorkspaces + const workspaces = await requestAllPages(client, operation.path, { + method: operation.method, + query: { sortBy: 'name', sortOrder: 'asc' }, + pageSize: 100, + limit: MAX_INTERACTIVE_WORKSPACES + 1, + }) + if (workspaces.length === 0) { + throw new SimApiError('The active API key cannot access any workspaces.', 0) + } + if (workspaces.length > MAX_INTERACTIVE_WORKSPACES) { + throw new SimApiError( + `The active API key can access more than ${MAX_INTERACTIVE_WORKSPACES} workspaces, which is too many to show interactively. Pass --workspace instead.`, + 0 + ) + } + + console.log('\nAvailable workspaces:') + for (const [index, workspace] of workspaces.entries()) { + console.log(` ${index + 1}) ${safeOneLine(workspace.name)} (${workspace.id})`) + } + + const prompt = createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = await prompt.question(`Choose a workspace [1-${workspaces.length}]: `) + const selected = Number(answer.trim()) + if (!Number.isInteger(selected) || selected < 1 || selected > workspaces.length) { + throw new SimApiError( + `Invalid workspace selection "${safeOneLine(answer)}". Choose a number from 1 to ${workspaces.length}.`, + 0 + ) + } + return workspaces[selected - 1] + } finally { + prompt.close() + } +} + +function addProfileCommand(): Command { + return new Command('add') + .description('Add a workspace profile that shares the active stored login') + .argument('', 'Name for the new profile') + .option('-w, --workspace ', 'Existing workspace to use; omit for an interactive picker') + .action(async (profileName: string, _options: unknown, command: Command) => { + validateNewProfileName(profileName) + + const { client, profile } = clientFrom(command) + const authProfile = requireStoredAuthentication(profile) + const workspaceId = globalsOf(command).workspace + const workspace = workspaceId + ? await getWorkspaceById(client, workspaceId) + : await chooseWorkspace(client) + + writeConfigProfile(profileName, { + auth_profile: authProfile, + workspace: workspace.id, + }) + + console.log(chalk.green(`✓ Added profile "${profileName}" in ${configPath()}`)) + console.log(` Workspace: ${safeOneLine(workspace.name)} (${workspace.id})`) + console.log(` Authentication: ${authProfile}`) + console.log(chalk.dim(` Try: sim --profile ${profileName} whoami`)) + }) +} + export function loginCommand(): Command { return new Command('login') .description('Authorize this terminal and store an API key for the profile') @@ -97,6 +232,14 @@ export function loginCommand(): Command { .action( async (options: { scope: string; browser: boolean; yes?: boolean }, command: Command) => { const profile = profileFrom(command) + const authProfile = resolveAuthenticationProfileName(profile.name) + + if (authProfile !== profile.name) { + throw new SimApiError( + `Profile "${profile.name}" shares authentication with "${authProfile}". Run: sim login --profile ${authProfile}`, + 0 + ) + } if (options.scope !== 'platform' && options.scope !== 'copilot') { throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) @@ -180,18 +323,33 @@ export function logoutCommand(): Command { .description("Remove the profile's stored API key") .option('--all', 'Remove the profile entirely, including its settings') .action((options: { all?: boolean }, command: Command) => { - const profile = profileFrom(command) - if (options.all) { - const removed = deleteProfile(profile.name) + const profileName = selectedProfileName(command) + const dependents = listAuthenticationDependents(profileName) + if (dependents.length > 0) { + throw new SimApiError( + `Cannot remove authentication profile "${profileName}" because it is used by: ${dependents.join(', ')}. Remove those profiles first.`, + 0 + ) + } + const removed = deleteProfile(profileName) if (!removed.config && !removed.credentials) { - console.log(chalk.dim(`Nothing stored for profile "${profile.name}".`)) + console.log(chalk.dim(`Nothing stored for profile "${profileName}".`)) return } - console.log(chalk.green(`✓ Removed profile "${profile.name}".`)) + console.log(chalk.green(`✓ Removed profile "${profileName}".`)) return } + const profile = profileFrom(command) + const authProfile = resolveAuthenticationProfileName(profile.name) + if (authProfile !== profile.name) { + throw new SimApiError( + `Profile "${profile.name}" shares authentication with "${authProfile}". Log out of the authentication profile instead: sim logout --profile ${authProfile}`, + 0 + ) + } + if (!readCredentialsProfile(profile.name).api_key) { console.log(chalk.dim(`No stored key for profile "${profile.name}".`)) return @@ -392,21 +550,31 @@ export function whoamiCommand(): Command { } export function profilesCommand(): Command { - return new Command('profiles') + const command = new Command('profiles') .alias('profile') - .description('List the profiles defined in the config and credentials files') - .action((_options: unknown, command: Command) => { - const profiles = listProfiles() - if (profiles.length === 0) { - console.log(chalk.dim('No profiles yet. Run: sim login')) - return - } + .description('List profiles or add a workspace profile that shares a stored login') - const active = profileFrom(command).name - for (const name of profiles) { - const marker = name === active ? chalk.green('*') : ' ' - const hasKey = Boolean(readCredentialsProfile(name).api_key) - console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}`) - } - }) + const printProfiles = (_options: unknown, actionCommand: Command): void => { + const profiles = listProfiles() + if (profiles.length === 0) { + console.log(chalk.dim('No profiles yet. Run: sim login')) + return + } + + const active = selectedProfileName(actionCommand) + for (const name of profiles) { + const marker = name === active ? chalk.green('*') : ' ' + const authProfile = resolveAuthenticationProfileName(name) + const hasKey = Boolean(readCredentialsProfile(authProfile).api_key) + const authentication = authProfile === name ? '' : chalk.dim(` (auth: ${authProfile})`) + console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}${authentication}`) + } + } + + command.action(printProfiles) + command.addCommand( + new Command('list').description('List configured profiles').action(printProfiles) + ) + command.addCommand(addProfileCommand()) + return command } diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index d06a956d541..cb06fda344a 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -3,11 +3,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readConfigProfile } from '../config/index' +import { readConfigProfile, writeConfigProfile, writeCredentialsProfile } from '../config/index' import { configureCommand } from './configure' +const mocks = vi.hoisted(() => ({ profileName: 'default' })) + vi.mock('../context', () => ({ - profileFrom: () => ({ name: 'default' }), + profileFrom: () => ({ name: mocks.profileName }), })) let dir: string @@ -21,6 +23,7 @@ function run(...args: string[]): Promise { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sim-cli-')) process.env.SIM_CONFIG_DIR = dir + mocks.profileName = 'default' vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -49,4 +52,19 @@ describe('configure --set-endpoint', () => { await run('--set-endpoint', 'http://localhost:3000//') expect(readConfigProfile('default')).toMatchObject({ endpoint: 'http://localhost:3000' }) }) + + it('refuses to set an endpoint locally on a shared workspace profile', async () => { + writeConfigProfile('default', { endpoint: 'https://sim.example' }) + writeCredentialsProfile('default', 'stored-key') + writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' }) + mocks.profileName = 'acme' + + await expect(run('--set-endpoint', 'https://other.example')).rejects.toThrow( + 'Profile "acme" shares its endpoint with authentication profile "default".' + ) + expect(readConfigProfile('acme')).toEqual({ + auth_profile: 'default', + workspace: 'ws_acme', + }) + }) }) diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 265cc0b4ecb..4d52e1b2510 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -1,6 +1,12 @@ import chalk from 'chalk' import { Command } from 'commander' -import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index' +import { + configPath, + OUTPUT_FORMATS, + readConfigProfile, + resolveAuthenticationProfileName, + writeConfigProfile, +} from '../config/index' import { normalizeEndpoint } from '../config/profile' import { profileFrom } from '../context' import { SimApiError } from '../http/client' @@ -28,9 +34,16 @@ export function configureCommand(): Command { command: Command ) => { const profile = profileFrom(command) + const authProfile = resolveAuthenticationProfileName(profile.name) const updates: Record = {} if (options.setEndpoint) { + if (authProfile !== profile.name) { + throw new SimApiError( + `Profile "${profile.name}" shares its endpoint with authentication profile "${authProfile}". Run: sim configure --profile ${authProfile} --set-endpoint ${options.setEndpoint}`, + 0 + ) + } updates.endpoint = normalizeEndpoint(options.setEndpoint, '--set-endpoint') } if (options.setWorkspace) updates.workspace = options.setWorkspace @@ -48,6 +61,12 @@ export function configureCommand(): Command { if (!['endpoint', 'workspace', 'output'].includes(key)) { throw new SimApiError(`Cannot unset "${key}". Use endpoint, workspace, or output.`, 0) } + if (key === 'endpoint' && authProfile !== profile.name) { + throw new SimApiError( + `Profile "${profile.name}" shares its endpoint with authentication profile "${authProfile}". Run: sim configure --profile ${authProfile} --unset endpoint`, + 0 + ) + } updates[key] = null } diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 79b5751a0b0..699cc718f04 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -3,6 +3,7 @@ export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, deleteProfile, + listAuthenticationDependents, listProfiles, OUTPUT_FORMATS, type OutputFormat, @@ -11,6 +12,7 @@ export { type ResolvedProfile, readConfigProfile, readCredentialsProfile, + resolveAuthenticationProfileName, resolveProfile, type SettingSource, writeConfigProfile, diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 1d457be9b2a..d005df41720 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -6,8 +6,10 @@ import { configPath, credentialsPath } from './paths' import { DEFAULT_ENDPOINT, deleteProfile, + listAuthenticationDependents, listProfiles, OUTPUT_FORMATS, + resolveAuthenticationProfileName, resolveProfile, writeConfigProfile, writeCredentialsProfile, @@ -71,6 +73,88 @@ describe('profile resolution', () => { }) }) + it('keeps existing profiles self-authenticating when auth_profile is absent', () => { + writeConfigProfile('dev', { endpoint: 'https://dev.example', workspace: 'ws_dev' }) + writeCredentialsProfile('dev', 'key_dev') + + expect(resolveAuthenticationProfileName('dev')).toBe('dev') + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + endpoint: 'https://dev.example', + workspaceId: 'ws_dev', + apiKey: 'key_dev', + }) + }) + + it('shares only authentication and endpoint through auth_profile', () => { + writeConfigProfile('default', { + endpoint: 'https://sim.example', + workspace: 'ws_default', + output: 'yaml', + }) + writeCredentialsProfile('default', 'key_default') + writeConfigProfile('acme', { + auth_profile: 'default', + workspace: 'ws_acme', + output: 'json', + }) + + expect(resolveAuthenticationProfileName('acme')).toBe('default') + expect(resolveProfile({ profile: 'acme' })).toMatchObject({ + name: 'acme', + endpoint: 'https://sim.example', + workspaceId: 'ws_acme', + output: 'json', + apiKey: 'key_default', + sources: { + endpoint: 'config', + workspaceId: 'config', + output: 'config', + apiKey: 'credentials', + }, + }) + }) + + it('fails fast on empty, missing, self-referential, or chained auth profiles', () => { + writeConfigProfile('empty', { auth_profile: '' }) + expect(() => resolveProfile({ profile: 'empty' })).toThrow( + 'Profile "empty" has an empty auth_profile.' + ) + + writeConfigProfile('missing', { auth_profile: 'gone' }) + expect(() => resolveProfile({ profile: 'missing' })).toThrow( + 'Profile "missing" references missing auth_profile "gone".' + ) + + writeConfigProfile('self', { auth_profile: 'self' }) + expect(() => resolveProfile({ profile: 'self' })).toThrow( + 'Profile "self" cannot use itself as auth_profile.' + ) + + writeConfigProfile('base', { auth_profile: 'root' }) + writeCredentialsProfile('root', 'key_root') + writeConfigProfile('chained', { auth_profile: 'base' }) + expect(() => resolveProfile({ profile: 'chained' })).toThrow( + 'Profile "chained" references auth_profile "base", which also has auth_profile set.' + ) + }) + + it('rejects ambiguous local authentication settings on a shared profile', () => { + writeCredentialsProfile('default', 'key_default') + writeConfigProfile('endpoint-alias', { + auth_profile: 'default', + endpoint: 'https://other.example', + }) + expect(() => resolveProfile({ profile: 'endpoint-alias' })).toThrow( + 'Profile "endpoint-alias" cannot set both auth_profile and endpoint.' + ) + + writeConfigProfile('key-alias', { auth_profile: 'default' }) + writeCredentialsProfile('key-alias', 'key_alias') + expect(() => resolveProfile({ profile: 'key-alias' })).toThrow( + 'Profile "key-alias" cannot set both auth_profile and its own API key.' + ) + }) + it('lets a flag beat the environment, and the environment beat the file', () => { writeConfigProfile('default', { endpoint: 'https://file.example' }) @@ -186,6 +270,16 @@ describe('profile resolution', () => { expect(listProfiles()).toEqual(['ci', 'default', 'dev']) }) + it('lists direct authentication dependents without treating a bad self-reference as one', () => { + writeCredentialsProfile('default', 'key') + writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' }) + writeConfigProfile('beta', { auth_profile: 'default', workspace: 'ws_beta' }) + writeConfigProfile('broken', { auth_profile: 'broken' }) + + expect(listAuthenticationDependents('default')).toEqual(['acme', 'beta']) + expect(listAuthenticationDependents('broken')).toEqual([]) + }) + it('deletes a profile from both files', () => { writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) writeCredentialsProfile('dev', 'key') diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 9a91e704401..3a58267d51f 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -101,6 +101,54 @@ export function readCredentialsProfile(profile: string): Record return getSection(readIni(credentialsPath()), profile) ?? {} } +/** + * Resolves the one stored identity a profile authenticates through. + * + * Existing profiles authenticate through their same-named credentials section. + * A workspace alias may instead name one direct `auth_profile`; references are + * deliberately non-recursive so a hand-edited cycle or missing target fails + * with the setting that needs repair rather than surfacing later as "no key". + */ +export function resolveAuthenticationProfileName(profile: string): string { + const config = readConfigProfile(profile) + if (!Object.hasOwn(config, 'auth_profile')) return profile + + const authProfile = config.auth_profile.trim() + if (!authProfile) { + throw new ProfileConfigError(`Profile "${profile}" has an empty auth_profile.`) + } + if (authProfile === profile) { + throw new ProfileConfigError( + `Profile "${profile}" cannot use itself as auth_profile. Remove the auth_profile setting instead.` + ) + } + if (Object.hasOwn(config, 'endpoint')) { + throw new ProfileConfigError( + `Profile "${profile}" cannot set both auth_profile and endpoint. Set the endpoint on authentication profile "${authProfile}".` + ) + } + if (readCredentialsProfile(profile).api_key) { + throw new ProfileConfigError( + `Profile "${profile}" cannot set both auth_profile and its own API key. Remove one of them.` + ) + } + + const authConfig = readConfigProfile(authProfile) + const credentials = readCredentialsProfile(authProfile) + if (Object.keys(authConfig).length === 0 && Object.keys(credentials).length === 0) { + throw new ProfileConfigError( + `Profile "${profile}" references missing auth_profile "${authProfile}".` + ) + } + if (Object.hasOwn(authConfig, 'auth_profile')) { + throw new ProfileConfigError( + `Profile "${profile}" references auth_profile "${authProfile}", which also has auth_profile set. Authentication profile references cannot be chained.` + ) + } + + return authProfile +} + /** Every profile named by either file, deduplicated and sorted. */ export function listProfiles(): string[] { const names = new Set() @@ -116,6 +164,14 @@ export function listProfiles(): string[] { return [...names].sort() } +/** Profiles that directly share the named profile's stored authentication. */ +export function listAuthenticationDependents(authProfile: string): string[] { + return listProfiles().filter( + (profile) => + profile !== authProfile && readConfigProfile(profile).auth_profile?.trim() === authProfile + ) +} + export function writeConfigProfile(profile: string, values: Record): void { const doc = readIni(configPath()) setSectionValues(doc, configSectionName(profile), values) @@ -195,13 +251,15 @@ function resolve( export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile { const name = overrides.profile || process.env.SIM_PROFILE || DEFAULT_PROFILE const config = readConfigProfile(name) - const credentials = readCredentialsProfile(name) + const authProfile = resolveAuthenticationProfileName(name) + const authConfig = authProfile === name ? config : readConfigProfile(authProfile) + const credentials = readCredentialsProfile(authProfile) const endpoint = resolve( [ ['flag', overrides.endpoint], ['env', process.env.SIM_ENDPOINT], - ['config', config.endpoint], + ['config', authConfig.endpoint], ], DEFAULT_ENDPOINT, 'default' diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index a78817fb0ce..3caf3b99e85 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -18,6 +18,7 @@ with -P, --profile, or SIM_PROFILE. Examples: $ sim login Authorize the default profile + $ sim profile add acme --workspace ws_123 Reuse that login for a workspace $ sim login --profile dev --endpoint http://localhost:3000 $ sim workflows list $ sim logs list --level error --limit 20