From eba66055730218dc13e78244b8855ae120a07ee7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 00:59:33 -0700 Subject: [PATCH 1/7] feat(permission-groups): expose administration through API CLI and MCP --- .../docs/content/docs/api-reference/meta.json | 1 + apps/docs/content/docs/cli/commands.mdx | 1 + apps/docs/content/docs/cli/meta.json | 1 + .../content/docs/cli/permission-groups.mdx | 263 + apps/docs/content/docs/cli/reference.mdx | 257 + apps/docs/openapi-v2-resources.json | 12735 ++++++++++------ .../[groupId]/members/bulk/route.ts | 229 +- .../[groupId]/members/route.ts | 423 +- .../permission-groups/[groupId]/route.test.ts | 45 +- .../[id]/permission-groups/[groupId]/route.ts | 464 +- .../[id]/permission-groups/route.ts | 318 +- .../[id]/permission-groups/utils.test.ts | 65 - .../[id]/permission-groups/utils.ts | 159 - .../organizations/[id]/workspaces/route.ts | 61 +- .../[groupId]/members/[memberId]/route.ts | 16 + .../[groupId]/members/bulk/route.ts | 16 + .../[groupId]/members/route.ts | 57 + .../permission-groups/[groupId]/route.ts | 47 + .../permission-groups/route.test.ts | 373 + .../permission-groups/route.ts | 59 + .../permission-groups/workspaces/route.ts | 41 + .../lib/api/contracts/permission-groups.ts | 75 +- .../v2/__tests__/list-pagination.test.ts | 22 + .../v2/__tests__/permission-groups.test.ts | 73 + .../contracts/v2/openapi/permission-groups.ts | 395 + .../lib/api/contracts/v2/openapi/resources.ts | 10 +- .../lib/api/contracts/v2/permission-groups.ts | 336 + .../lib/api/mcp/generated/v2-operations.ts | 122 + .../api/server/permission-group-presenters.ts | 12 + .../api/server/routes/permission-groups.ts | 30 + .../lib/copilot/generated/docs-manifest.ts | 1 + .../authorized-permission-group-use-case.ts | 80 + .../application/operations.ts | 57 + .../application/use-cases.test.ts | 206 + .../application/use-cases.ts | 200 + apps/sim/lib/permission-groups/constants.ts | 1 + apps/sim/lib/permission-groups/errors.ts | 64 + apps/sim/lib/permission-groups/fields.ts | 11 +- .../permission-groups/group-manager.test.ts | 157 + .../lib/permission-groups/group-manager.ts | 245 + apps/sim/lib/permission-groups/list.ts | 167 + .../permission-groups/member-manager.test.ts | 137 + .../lib/permission-groups/member-manager.ts | 195 + apps/sim/lib/permission-groups/repository.ts | 90 + packages/sim-cli/src/contract/commands.ts | 64 + packages/sim-cli/src/generated/v2-api.ts | 772 + packages/sim-cli/src/http/client.test.ts | 4 + packages/sim-cli/src/runtime/build.test.ts | 61 + packages/sim-cli/src/runtime/build.ts | 1 + packages/sim-cli/src/runtime/options.ts | 3 + scripts/openapi/documents.test.ts | 4 +- 51 files changed, 12607 insertions(+), 6619 deletions(-) create mode 100644 apps/docs/content/docs/cli/permission-groups.mdx delete mode 100644 apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts delete mode 100644 apps/sim/app/api/organizations/[id]/permission-groups/utils.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.test.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/permission-groups.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts create mode 100644 apps/sim/lib/api/contracts/v2/permission-groups.ts create mode 100644 apps/sim/lib/api/server/permission-group-presenters.ts create mode 100644 apps/sim/lib/api/server/routes/permission-groups.ts create mode 100644 apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts create mode 100644 apps/sim/lib/permission-groups/application/operations.ts create mode 100644 apps/sim/lib/permission-groups/application/use-cases.test.ts create mode 100644 apps/sim/lib/permission-groups/application/use-cases.ts create mode 100644 apps/sim/lib/permission-groups/constants.ts create mode 100644 apps/sim/lib/permission-groups/errors.ts create mode 100644 apps/sim/lib/permission-groups/group-manager.test.ts create mode 100644 apps/sim/lib/permission-groups/group-manager.ts create mode 100644 apps/sim/lib/permission-groups/list.ts create mode 100644 apps/sim/lib/permission-groups/member-manager.test.ts create mode 100644 apps/sim/lib/permission-groups/member-manager.ts create mode 100644 apps/sim/lib/permission-groups/repository.ts diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index d6d47878d24..0bc4f2fc0e0 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -17,6 +17,7 @@ "(generated)/files", "(generated)/knowledge-bases", "(generated)/workspaces", + "(generated)/permission-groups", "(generated)/workspace-sync", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index b1c1e6f400a..676fc6b31da 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -44,6 +44,7 @@ These apply to every command, and may be written before or after it. | [`sim logs`](/cli/logs) | Manage logs | | [`sim mcp-servers`](/cli/mcp-servers) | Manage mcp servers | | [`sim meta`](/cli/meta) | Manage meta | +| [`sim permission-groups`](/cli/permission-groups) | Manage permission groups | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim selectors`](/cli/selectors) | Manage selectors | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index de9eac6e055..842ea3a5084 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -27,6 +27,7 @@ "logs", "mcp-servers", "meta", + "permission-groups", "sandboxes", "secrets", "selectors", diff --git a/apps/docs/content/docs/cli/permission-groups.mdx b/apps/docs/content/docs/cli/permission-groups.mdx new file mode 100644 index 00000000000..5e00885d9f8 --- /dev/null +++ b/apps/docs/content/docs/cli/permission-groups.mdx @@ -0,0 +1,263 @@ +--- +title: Permission Groups +description: Manage permission groups — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +`sim permission-groups` is also spelled `sim permission-group`. + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Add permission group member + +```bash +sim permission-groups members add [options] +``` + +Add Permission Group Member (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--user-id ` | Yes | Existing organization member to add. | + + + +## Bulk add permission group members + +```bash +sim permission-groups members batch-add [options] +``` + +Bulk Add Permission Group Members (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | +| `--add-all-organization-members` | No | Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds. | +| `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | + + + +## List permission group members + +```bash +sim permission-groups members list [options] +``` + +List Permission Group Members (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `assignedAt`, `userId`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +## Remove permission group member + +```bash +sim permission-groups members remove [options] +``` + +Remove Permission Group Member (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `memberId` | Yes | Membership assignment identifier returned by List Permission Group Members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Create permission group + +```bash +sim permission-groups create [options] +``` + +Create Permission Group (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--name ` | Yes | Group name, unique within the organization. | +| `--description ` | No | Optional group description. | +| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | +| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-is-default` | No | Send --is-default as false. | +| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | + + + +## Delete permission group + +```bash +sim permission-groups delete [options] +``` + +Delete Permission Group (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Get permission group + +```bash +sim permission-groups get [options] +``` + +Get Permission Group (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | + + + +## List permission groups + +```bash +sim permission-groups list [options] +``` + +List Permission Groups (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--search ` | No | Case-insensitive substring match against the group name. | +| `--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 `0`. | + + + +## List permission group workspaces + +```bash +sim permission-groups workspaces [options] +``` + +List Permission Group Workspaces (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--search ` | No | Case-insensitive substring match against the workspace name. | +| `--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`, `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +## Update permission group + +```bash +sim permission-groups update [options] +``` + +Update Permission Group (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--name ` | No | Group name, unique within the organization. | +| `--description ` | No | Group description. Null clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | +| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | +| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-is-default` | No | Send --is-default as false. | +| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | + + diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 483d1677144..d317bf8f0a0 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2996,6 +2996,263 @@ Show what this API supports and which limits apply sim meta status ``` +## sim permission-groups + +Also spelled `sim permission-group`. + +### sim permission-groups members add + +Add Permission Group Member (OAuth login or personal API key required) + +```bash +sim permission-groups members add [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--user-id ` | Yes | Existing organization member to add. | + + + +### sim permission-groups members batch-add + +Bulk Add Permission Group Members (OAuth login or personal API key required) + +```bash +sim permission-groups members batch-add [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | +| `--add-all-organization-members` | No | Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds. | +| `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | + + + +### sim permission-groups members list + +List Permission Group Members (OAuth login or personal API key required) + +```bash +sim permission-groups members list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `assignedAt`, `userId`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +### sim permission-groups members remove + +Remove Permission Group Member (OAuth login or personal API key required) + +```bash +sim permission-groups members remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `memberId` | Yes | Membership assignment identifier returned by List Permission Group Members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--group ` | Yes | Permission group identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim permission-groups create + +Create Permission Group (OAuth login or personal API key required) + +```bash +sim permission-groups create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--name ` | Yes | Group name, unique within the organization. | +| `--description ` | No | Optional group description. | +| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | +| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-is-default` | No | Send --is-default as false. | +| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | + + + +### sim permission-groups delete + +Delete Permission Group (OAuth login or personal API key required) + +```bash +sim permission-groups delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim permission-groups get + +Get Permission Group (OAuth login or personal API key required) + +```bash +sim permission-groups get [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | + + + +### sim permission-groups list + +List Permission Groups (OAuth login or personal API key required) + +```bash +sim permission-groups list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--search ` | No | Case-insensitive substring match against the group name. | +| `--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 `0`. | + + + +### sim permission-groups workspaces + +List Permission Group Workspaces (OAuth login or personal API key required) + +```bash +sim permission-groups workspaces [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--search ` | No | Case-insensitive substring match against the workspace name. | +| `--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`, `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +### sim permission-groups update + +Update Permission Group (OAuth login or personal API key required) + +```bash +sim permission-groups update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `groupId` | Yes | Permission group identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization that owns the permission group. | +| `--name ` | No | Group name, unique within the organization. | +| `--description ` | No | Group description. Null clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | +| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | +| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-is-default` | No | Send --is-default as false. | +| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | + + + ## sim sandboxes Also spelled `sim sandbox`. diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 533dcf0f5f3..bb782cf1f8b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API v2 — Workspace Resources", - "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, and the block, tool, and connector-type catalogs.", + "title": "Sim API v2 — Resources", + "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, organization permission groups, and the block, tool, and connector-type catalogs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Permission Groups", + "description": "Manage organization permission groups, their restrictions, and membership." + }, { "name": "Meta", "description": "Discover what the calling API credential can reach." @@ -4739,1530 +4743,1359 @@ } } } - } - }, - "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." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "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/organizations/{organizationId}/permission-groups": { + "get": { + "operationId": "listPermissionGroups", + "summary": "List Permission Groups", + "description": "List permission groups in an organization with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "permission_groups.list", + "x-oauth-scope": "api:read", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the group name.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } + "description": "Case-insensitive substring match against the group name.", + "type": "string", + "minLength": 1, + "maxLength": 200 } - } - } - }, - "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": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } + "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"] } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum permission groups to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "The request conflicts with the current state of the resource" - } + "default": 50, + "description": "Maximum permission groups to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } - } - } - }, - "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": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" + "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": "List Permission Groups result.", + "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/ListPermissionGroupsResponse" + } } } + }, + "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" } } }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + "post": { + "operationId": "createPermissionGroup", + "summary": "Create Permission Group", + "description": "Create a permission group. A non-default group requires workspaces and initially governs everyone in them. Creating a default group demotes the previous default to an inactive group until it is assigned workspaces. Overlapping all-member scopes conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.create", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + ], + "requestBody": { + "required": true, + "description": "Create Permission Group inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePermissionGroupRequest" + } + } } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "responses": { + "201": { + "description": "Create Permission Group result.", + "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": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePermissionGroupResponse" } } } + }, + "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" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}": { + "get": { + "operationId": "getPermissionGroup", + "summary": "Get Permission Group", + "description": "Get a permission group and its resolved restrictions. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "permission_groups.read", + "x-oauth-scope": "api:read", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } - } - } - }, - "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": { + }, + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } + "type": "string", + "minLength": 1, + "description": "Permission group identifier." } } - } - } - }, - "schemas": { - "V2ActionableForbiddenDetails": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" - } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." + "responses": { + "200": { + "description": "Get Permission Group result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "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." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPermissionGroupResponse" + } + } } - } - ] - }, - "V2Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." }, - "name": { - "type": "string", - "description": "Workspace display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "color": { - "type": "string", - "description": "Workspace color as a hexadecimal color value." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "logoUrl": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workspace logo URL, or null when none is configured." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "memberCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of effective members, including inherited organization administrators." + "404": { + "$ref": "#/components/responses/NotFound" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was created." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was last updated." - } - }, - "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], - "additionalProperties": false, - "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." + "500": { + "$ref": "#/components/responses/InternalError" }, - "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 credential.", - "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 + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - ] + } }, - "GetWorkspaceResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Workspace" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workspace response", - "description": "Public metadata for one workspace.", - "examples": [ + "patch": { + "operationId": "updatePermissionGroup", + "summary": "Update Permission Group", + "description": "Update a permission group. Omitted fields remain unchanged; config keys are patched and supplied arrays replace their lists. Promoting a group to default demotes the previous default; demoting without workspaceIds leaves it inactive. Overlapping member or all-member scopes conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.update", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ { - "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" + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } - } - ] - }, - "V2WorkspaceMember": { - "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": "Member email address and public member identifier." - }, - "name": { - "type": "string", - "description": "Member display name." }, - "image": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Update Permission Group inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePermissionGroupRequest" } - ], - "description": "Member profile image URL, or null when absent." - }, - "role": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Effective role in the workspace." - }, - "isExternal": { - "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." - }, - "joinedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when access was granted." + } } }, - "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], - "additionalProperties": false, - "title": "Workspace member", - "description": "An effective workspace member and their public access role." - }, - "ListWorkspaceMembersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2WorkspaceMember" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "responses": { + "200": { + "description": "Update Permission Group result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 workspace members response", - "description": "A cursor-paginated page of effective workspace members.", - "examples": [ - { - "data": [ - { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "role": "admin", - "isExternal": false, - "joinedAt": "2026-01-15T10:30:00.000Z" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePermissionGroupResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2McpServer": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique server identifier derived from the workspace and endpoint URL." - }, - "name": { - "type": "string", - "description": "Server display name." + } }, - "description": { - "description": "Optional server description.", - "type": "string" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "transport": { - "default": "streamable-http", - "description": "Transport used to communicate with the server.", - "type": "string", - "enum": ["streamable-http"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "authType": { - "description": "Authentication method used by the server.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "description": "Server endpoint URL.", - "type": "string" + "404": { + "$ref": "#/components/responses/NotFound" }, - "timeout": { - "description": "Per-request timeout in milliseconds.", - "type": "number" + "409": { + "$ref": "#/components/responses/Conflict" }, - "retries": { - "description": "Number of retries attempted per request.", - "type": "number" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "connectionStatus": { - "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", - "type": "string", - "enum": ["connected", "disconnected", "error"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "lastError": { - "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", - "anyOf": [ - { - "type": "string" + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deletePermissionGroup", + "summary": "Delete Permission Group", + "description": "Permanently delete a permission group and its membership assignments. Members then inherit any other applicable restrictions. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.delete", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." + } + }, + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." + } + } + ], + "responses": { + "200": { + "description": "Delete Permission Group result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "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/DeletePermissionGroupResponse" + } + } + } }, - "toolCount": { - "description": "Number of tools discovered on the server.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastToolsRefresh": { - "description": "ISO 8601 timestamp of the most recent tool-list refresh.", - "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))$" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", - "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))$" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "description": "ISO 8601 timestamp when the server was registered.", - "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))$" + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "description": "ISO 8601 timestamp when the server was last updated.", - "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))$" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier, when configured.", - "type": "string" + "500": { + "$ref": "#/components/responses/InternalError" }, - "hasHeaders": { - "type": "boolean", - "description": "Whether any request headers are configured." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members": { + "get": { + "operationId": "listPermissionGroupMembers", + "summary": "List Permission Group Members", + "description": "List explicit membership assignments in a permission group with cursor pagination. An empty inherit group applies to everyone in its workspaces. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "permission_groups.members.list", + "x-oauth-scope": "api:read", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." + } }, - "headerNames": { - "type": "array", - "items": { + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { "type": "string", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." + "minLength": 1, + "description": "Permission group identifier." + } }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "assignedAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["assignedAt", "userId"] + } + }, + { + "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 group members to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum group members 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 + } } - }, - "required": [ - "id", - "name", - "transport", - "enabled", - "createdAt", - "updatedAt", - "hasHeaders", - "headerNames", - "hasOauthClientSecret" ], - "additionalProperties": false, - "title": "MCP server", - "description": "Public MCP server configuration without write-only credential values." - }, - "ListMcpServersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpServer" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "responses": { + "200": { + "description": "List Permission Group Members result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 MCP servers response", - "description": "MCP servers registered in the workspace.", - "examples": [ - { - "data": [ - { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPermissionGroupMembersResponse" + } } - ], - "nextCursor": null - } - ] - }, - "CreateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create MCP server response", - "description": "The registered MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "disconnected", - "lastError": null, - "toolCount": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false } - } - ] - }, - "CreateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to register the server." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "addPermissionGroupMember", + "summary": "Add Permission Group Member", + "description": "Assign an organization member to a permission group. An existing assignment or membership in another group targeting the same workspace returns a conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.members.add", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "minLength": 1, + "description": "Organization that owns the permission groups." + } + }, + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "description": "Permission group identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Add Permission Group Member inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddPermissionGroupMemberRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Add Permission Group Member result.", + "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/AddPermissionGroupMemberResponse" + } + } } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["workspaceId", "name", "url"], - "additionalProperties": false, - "title": "Create MCP server request", - "description": "Configuration for a new MCP server.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members/{memberId}": { + "delete": { + "operationId": "removePermissionGroupMember", + "summary": "Remove Permission Group Member", + "description": "Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.members.remove", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Docs server", - "url": "https://mcp.example.com/sse", - "authType": "headers", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } - } - ] - }, - "GetMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get MCP server response", - "description": "One MCP server without write-only credentials.", - "examples": [ + }, { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." } - } - ] - }, - "UpdateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update MCP server response", - "description": "The updated MCP server.", - "examples": [ + }, { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": false, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "memberId", + "in": "path", + "required": true, + "description": "Membership assignment identifier returned by List Permission Group Members.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Membership assignment identifier returned by List Permission Group Members." } } - ] - }, - "UpdateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the MCP server." + ], + "responses": { + "200": { + "description": "Remove Permission Group Member result.", + "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/RemovePermissionGroupMemberResponse" + } + } + } }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "409": { + "$ref": "#/components/responses/Conflict" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members/bulk": { + "post": { + "operationId": "bulkAddPermissionGroupMembers", + "summary": "Bulk Add Permission Group Members", + "description": "Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "permission_groups.members.bulk_add", + "x-oauth-scope": "api:write", + "tags": ["Permission Groups"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "minLength": 1, + "description": "Organization that owns the permission groups." + } + }, + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "description": "Permission group identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Bulk Add Permission Group Members inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAddPermissionGroupMembersRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Bulk Add Permission Group Members result.", + "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/BulkAddPermissionGroupMembersResponse" + } + } } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update MCP server request", - "description": "MCP server fields to change; omitted fields retain their stored values.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "enabled": false - } - ] - }, - "V2McpServerDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted MCP server." + "409": { + "$ref": "#/components/responses/Conflict" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the server was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete MCP server data", - "description": "MCP server deletion acknowledgement." - }, - "DeleteMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServerDeleteData" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete MCP server response", - "description": "Acknowledgement that the MCP server was deleted.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/workspaces": { + "get": { + "operationId": "listPermissionGroupWorkspaces", + "summary": "List Permission Group Workspaces", + "description": "List organization workspaces available for permission-group scope selection with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "permission_groups.workspaces.list", + "x-oauth-scope": "api:read", + "tags": ["Permission Groups"], + "parameters": [ { - "data": { - "id": "mcp-3f7a9c21", - "deleted": true + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } - } - ] - }, - "V2McpTool": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tool name, as the MCP server reports it." }, - "description": { - "description": "Tool description reported by the server.", - "type": "string" + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the workspace name.", + "schema": { + "description": "Case-insensitive substring match against the workspace name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "inputSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "object", - "description": "JSON Schema type of the argument object. MCP requires `object`." - }, - "properties": { - "description": "Argument schemas keyed by argument name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Server-defined JSON Schema for one tool argument." - } - }, - "required": { - "description": "Names of the arguments the tool requires.", - "type": "array", - "items": { - "type": "string", - "description": "Name of a required argument." - } - } - }, - "required": ["type"], - "additionalProperties": { - "description": "Additional JSON Schema keyword reported by the server." - }, - "description": "JSON Schema for the tool's arguments, as reported by the server." + { + "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", "id"] + } }, - "serverId": { - "type": "string", - "description": "Identifier of the MCP server exposing the tool." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "serverName": { - "type": "string", - "description": "Display name of the MCP server exposing the tool." - } - }, - "required": ["name", "inputSchema", "serverId", "serverName"], - "additionalProperties": false, - "title": "MCP tool", - "description": "A tool exposed by a registered MCP server." - }, - "ListMcpServerToolsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpTool" - }, - "description": "Items in the current page." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum organization workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum organization workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "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": "List Permission Group Workspaces result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP server tools response", - "description": "Tools exposed by the MCP server.", - "examples": [ - { - "data": [ - { - "name": "search_docs", - "description": "Search the internal documentation", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search terms" - } - }, - "required": ["query"] - }, - "serverId": "mcp-3f7a9c21", - "serverName": "Docs server" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPermissionGroupWorkspacesResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2SkillSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." - } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Skill summary", - "description": "Public summary metadata for a workspace or built-in skill." + "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." }, - "ListSkillsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2SkillSummary" + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "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" }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" } - ], - "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 skills response", - "description": "Skill summaries available in the workspace.", - "examples": [ - { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" } - ], - "nextCursor": null + } } - ] + } }, - "V2Skill": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." - }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." - }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." - }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." - }, - "content": { - "type": "string", - "description": "Skill body containing the instructions given to the agent." + "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" + } + } + } } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], - "additionalProperties": false, - "title": "Skill", - "description": "A workspace or built-in skill including its instruction body." + } }, - "CreateSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create skill response", - "description": "The created skill including its content.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current state of the resource" + } } } - ] + } }, - "CreateSkillRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the skill." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", - "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "One-line summary of when the skill applies." - }, - "content": { - "type": "string", - "minLength": 1, - "maxLength": 50000, - "description": "Skill body containing the instructions given to the agent." + "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" } }, - "required": ["workspaceId", "name", "description", "content"], - "additionalProperties": false, - "title": "Create skill request", - "description": "Definition of a new skill.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "refund-policy", - "description": "How support should handle refund requests", - "content": "# Refund policy\n\nAlways check the order date first." + "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" + } + } + } } - ] + } }, - "GetSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "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" } }, - "required": ["data"], - "additionalProperties": false, - "title": "Get skill response", - "description": "One skill including its full content.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } - ] + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." }, - "UpdateSkillResponse": { + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "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 context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." } }, - "required": ["data"], + "required": ["error"], "additionalProperties": false, - "title": "Update skill response", - "description": "The updated skill including its full content.", + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "Updated refund guidance", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." } } ] }, - "UpdateSkillRequest": { + "V2Workspace": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the skill." + "description": "Unique workspace identifier." }, "name": { - "description": "New kebab-case skill name.", "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + "description": "Workspace display name." }, - "description": { - "description": "New one-line summary of when the skill applies.", + "color": { "type": "string", - "minLength": 1, - "maxLength": 1024 + "description": "Workspace color as a hexadecimal color value." }, - "content": { - "description": "Replacement skill body.", - "type": "string", - "minLength": 1, - "maxLength": 50000 - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update skill request", - "description": "Skill fields to change; at least one editable field is required.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "description": "Updated refund guidance" - } - ] - }, - "V2SkillDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted skill." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the skill was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete skill data", - "description": "Skill deletion acknowledgement." - }, - "DeleteSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete skill response", - "description": "Acknowledgement that the skill was deleted.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true - } - } - ] - }, - "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": { + "logoUrl": { "anyOf": [ { "type": "string" @@ -6271,36 +6104,39 @@ "type": "null" } ], - "description": "Display name of the skill editor." + "description": "Workspace logo URL, or null when none is configured." }, - "image": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Profile image URL of the skill editor." + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of effective members, including inherited organization administrators." }, - "isWorkspaceAdmin": { - "type": "boolean", - "description": "Whether editor access is derived from workspace administration." + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the workspace was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the workspace was last updated." } }, - "required": ["email", "name", "image", "isWorkspaceAdmin"], + "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Skill editor", - "description": "Public identity fields for a user who can edit a skill." + "title": "Workspace", + "description": "Public metadata for an accessible workspace." }, - "ListSkillEditorsResponse": { + "ListWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2SkillEditor" + "$ref": "#/components/schemas/V2Workspace" }, "description": "Items in the current page." }, @@ -6318,218 +6154,267 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List skill editors response", - "description": "Public identity fields for users who can edit the skill.", + "title": "List workspaces response", + "description": "Public metadata for workspaces available to the credential.", "examples": [ { "data": [ { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "isWorkspaceAdmin": false + "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 } ] }, - "GrantSkillEditorResponse": { + "GetWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SkillEditor" + "$ref": "#/components/schemas/V2Workspace" } }, "required": ["data"], "additionalProperties": false, - "title": "Grant skill editor response", - "description": "Public identity fields for the editor.", + "title": "Get workspace response", + "description": "Public metadata for one workspace.", "examples": [ { "data": { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "isWorkspaceAdmin": false + "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" } } ] }, - "GrantSkillEditorRequest": { + "V2WorkspaceMember": { "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": { + "description": "Member email address and public member identifier." + }, + "name": { "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." + "description": "Member display name." }, - "revoked": { + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member profile image URL, or null when absent." + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Effective role in the workspace." + }, + "isExternal": { "type": "boolean", - "const": true, - "description": "Whether the explicit editor grant was revoked." + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when access was granted." } }, - "required": ["email", "revoked"], + "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], "additionalProperties": false, - "title": "Revoke skill editor data", - "description": "Skill editor revocation acknowledgement." + "title": "Workspace member", + "description": "An effective workspace member and their public access role." }, - "RevokeSkillEditorResponse": { + "ListWorkspaceMembersResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillEditorDeleteData" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2WorkspaceMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Revoke skill editor response", - "description": "Acknowledgement that the explicit editor grant was revoked.", + "title": "List workspace members response", + "description": "A cursor-paginated page of effective workspace members.", "examples": [ { - "data": { - "email": "jane@example.com", - "revoked": true - } + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "admin", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null } ] }, - "V2CustomTool": { + "V2McpServer": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique custom tool identifier." + "description": "Unique server identifier derived from the workspace and endpoint URL." }, - "title": { + "name": { "type": "string", - "description": "Display title, unique within the workspace." + "description": "Server display name." }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "description": { + "description": "Optional server description.", + "type": "string" + }, + "transport": { + "default": "streamable-http", + "description": "Transport used to communicate with the server.", + "type": "string", + "enum": ["streamable-http"] + }, + "authType": { + "description": "Authentication method used by the server.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "url": { + "description": "Server endpoint URL.", + "type": "string" + }, + "timeout": { + "description": "Per-request timeout in milliseconds.", + "type": "number" + }, + "retries": { + "description": "Number of retries attempted per request.", + "type": "number" + }, + "enabled": { + "type": "boolean", + "description": "Whether the server tools are available to workflows." + }, + "connectionStatus": { + "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", + "type": "string", + "enum": ["connected", "disconnected", "error"] + }, + "lastError": { + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", + "anyOf": [ + { + "type": "string" }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + ] }, - "code": { + "toolCount": { + "description": "Number of tools discovered on the server.", + "type": "number" + }, + "lastToolsRefresh": { + "description": "ISO 8601 timestamp of the most recent tool-list refresh.", "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + "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))$" + }, + "lastConnected": { + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", + "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))$" }, "createdAt": { + "description": "ISO 8601 timestamp when the server was registered.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was created." + "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))$" }, "updatedAt": { + "description": "ISO 8601 timestamp when the server was last updated.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was last updated." + "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))$" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier, when configured.", + "type": "string" + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured." + }, + "headerNames": { + "type": "array", + "items": { + "type": "string", + "description": "Configured header name." + }, + "description": "Names of configured request headers. Header values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored. The value is never returned." } }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + "title": "MCP server", + "description": "Public MCP server configuration without write-only credential values." }, - "ListCustomToolsResponse": { + "ListMcpServersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2McpServer" }, "description": "Items in the current page." }, @@ -6547,485 +6432,474 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "List MCP servers response", + "description": "MCP servers registered in the workspace.", "examples": [ { "data": [ { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } ], "nextCursor": null } ] }, - "CreateCustomToolResponse": { + "CreateMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Create MCP server response", + "description": "The registered MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "disconnected", + "lastError": null, + "toolCount": 0, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "CreateCustomToolRequest": { + "CreateMcpServerRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "description": "Workspace in which to register the server." }, - "title": { + "name": { "type": "string", "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "maxLength": 255, + "description": "Server display name." }, - "schema": { + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "transport": { + "description": "Transport protocol. Defaults to `streamable-http` on creation.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, "type": "object", - "properties": { - "type": { + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } + }, + "timeout": { + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Defaults to 3 on creation.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "maxLength": 512 }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + ] }, - "code": { - "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["workspaceId", "name", "url"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", + "title": "Create MCP server request", + "description": "Configuration for a new MCP server.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" + "name": "Docs server", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } } ] }, - "GetCustomToolResponse": { + "GetMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", + "title": "Get MCP server response", + "description": "One MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "UpdateCustomToolResponse": { + "UpdateMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", + "title": "Update MCP server response", + "description": "The updated MCP server.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + ] + }, + "UpdateMcpServerRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the custom tool." + "description": "Workspace that owns the MCP server." }, - "title": { - "description": "New display title for the tool.", + "name": { "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 255, + "description": "Server display name." }, - "schema": { - "description": "Replacement function declaration.", + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "transport": { + "description": "Transport protocol. Defaults to `streamable-http` on creation.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." - }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." - } + "propertyNames": { + "type": "string", + "minLength": 1 }, - "required": ["type", "function"], "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "type": "string", + "description": "Header value sent to the MCP server." } }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + "timeout": { + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Defaults to 3 on creation.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] } }, "required": ["workspaceId"], "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", + "title": "Update MCP server request", + "description": "MCP server fields to change; omitted fields retain their stored values.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" + "enabled": false } ] }, - "V2CustomToolDeleteData": { + "V2McpServerDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted custom tool." + "description": "Identifier of the deleted MCP server." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the custom tool was deleted." + "description": "Whether the server was deleted." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." + "title": "Delete MCP server data", + "description": "MCP server deletion acknowledgement." }, - "DeleteCustomToolResponse": { + "DeleteMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" + "$ref": "#/components/schemas/V2McpServerDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", + "title": "Delete MCP server response", + "description": "Acknowledgement that the MCP server was deleted.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", + "id": "mcp-3f7a9c21", "deleted": true } } ] }, - "V2Sandbox": { + "V2McpTool": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique sandbox identifier." - }, "name": { "type": "string", - "description": "Display name, unique within the workspace." + "description": "Tool name, as the MCP server reports it." }, - "language": { - "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + "description": { + "description": "Tool description reported by the server.", + "type": "string" }, - "dependencies": { - "type": "array", - "items": { - "type": "string" + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "object", + "description": "JSON Schema type of the argument object. MCP requires `object`." + }, + "properties": { + "description": "Argument schemas keyed by argument name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Server-defined JSON Schema for one tool argument." + } + }, + "required": { + "description": "Names of the arguments the tool requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required argument." + } + } }, - "description": "Package specifiers installed into the sandbox, one per entry." + "required": ["type"], + "additionalProperties": { + "description": "Additional JSON Schema keyword reported by the server." + }, + "description": "JSON Schema for the tool's arguments, as reported by the server." }, - "cliTools": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - }, - "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates." + "serverId": { + "type": "string", + "description": "Identifier of the MCP server exposing the tool." }, - "systemPackages": { + "serverName": { + "type": "string", + "description": "Display name of the MCP server exposing the tool." + } + }, + "required": ["name", "inputSchema", "serverId", "serverName"], + "additionalProperties": false, + "title": "MCP tool", + "description": "A tool exposed by a registered MCP server." + }, + "ListMcpServerToolsResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/V2McpTool" }, - "description": "Debian packages installed into the sandbox, one per entry." - }, - "buildStatus": { - "anyOf": [ - { - "type": "string", - "enum": ["pending", "building", "ready", "failed"] - }, - { - "type": "null" - } - ], - "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + "description": "Items in the current page." }, - "errorCode": { + "nextCursor": { "anyOf": [ { "type": "string" @@ -7034,82 +6908,81 @@ "type": "null" } ], - "description": "Classified build failure code, or `null`." - }, - "errorMessage": { - "anyOf": [ - { - "type": "string" - }, + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", + "examples": [ + { + "data": [ { - "type": "null" + "name": "search_docs", + "description": "Search the internal documentation", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms" + } + }, + "required": ["query"] + }, + "serverId": "mcp-3f7a9c21", + "serverName": "Docs server" } ], - "description": "Human-readable build failure summary, or `null`." + "nextCursor": null + } + ] + }, + "V2SkillSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, - "errorDetail": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Tail of the installer log for a failed build, or `null`." + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." }, - "builtAt": { - "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": "ISO 8601 timestamp when the current image finished building, or `null`." + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the sandbox was created." + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the sandbox was last updated." + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." } }, - "required": [ - "id", - "name", - "language", - "dependencies", - "cliTools", - "systemPackages", - "buildStatus", - "errorCode", - "errorMessage", - "errorDetail", - "builtAt", - "createdAt", - "updatedAt" - ], + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Sandbox", - "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." + "title": "Skill summary", + "description": "Public summary metadata for a workspace or built-in skill." }, - "ListSandboxesResponse": { + "ListSkillsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2SkillSummary" }, "description": "Items in the current page." }, @@ -7127,23 +7000,16 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List sandboxes response", - "description": "Sandboxes defined in the workspace.", + "title": "List skills response", + "description": "Skill summaries available in the workspace.", "examples": [ { "data": [ { "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "ready", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": "2026-06-20T14:05:40.000Z", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } @@ -7152,306 +7018,237 @@ } ] }, - "CreateSandboxResponse": { + "V2Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + }, + "content": { + "type": "string", + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], + "additionalProperties": false, + "title": "Skill", + "description": "A workspace or built-in skill including its instruction body." + }, + "CreateSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Create sandbox response", - "description": "The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.", + "title": "Create skill response", + "description": "The created skill including its content.", "examples": [ { "data": { "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "pending", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": null, + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." } } ] }, - "CreateSandboxRequest": { + "CreateSkillRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the sandbox." + "description": "Workspace in which to create the skill." }, "name": { "type": "string", "minLength": 1, "maxLength": 64, - "description": "Display name, unique within the workspace; 1 to 64 characters." + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." }, - "language": { + "description": { "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." - }, - "dependencies": { - "default": [], - "description": "Package specifiers installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } - }, - "cliTools": { - "default": [], - "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", - "maxItems": 10, - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - } + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." }, - "systemPackages": { - "default": [], - "description": "Debian packages installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Skill body containing the instructions given to the agent." } }, - "required": ["workspaceId", "name", "language"], + "required": ["workspaceId", "name", "description", "content"], "additionalProperties": false, - "title": "Create sandbox request", - "description": "Name, language, and dependency set of a new sandbox.", + "title": "Create skill request", + "description": "Definition of a new skill.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "systemPackages": ["graphviz"] + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." } ] }, - "GetSandboxResponse": { + "GetSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Get sandbox response", - "description": "One sandbox.", + "title": "Get skill response", + "description": "One skill including its full content.", "examples": [ { "data": { "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "ready", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": "2026-06-20T14:05:40.000Z", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." } } ] }, - "UpdateSandboxResponse": { + "UpdateSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Update sandbox response", - "description": "The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.", + "title": "Update skill response", + "description": "The updated skill including its full content.", "examples": [ { "data": { "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests", "pyarrow"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "pending", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": null, + "name": "refund-policy", + "description": "Updated refund guidance", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." } } ] }, - "UpdateSandboxRequest": { + "UpdateSkillRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the sandbox." + "description": "Workspace that owns the skill." }, "name": { - "description": "New display name, unique within the workspace; 1 to 64 characters.", + "description": "New kebab-case skill name.", "type": "string", "minLength": 1, - "maxLength": 64 + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, - "language": { - "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", + "description": { + "description": "New one-line summary of when the skill applies.", "type": "string", - "enum": ["javascript", "python"] - }, - "dependencies": { - "description": "Replacement package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } - }, - "cliTools": { - "description": "Replacement managed CLI list; replaces the whole list.", - "maxItems": 10, - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - } + "minLength": 1, + "maxLength": 1024 }, - "systemPackages": { - "description": "Replacement Debian package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "content": { + "description": "Replacement skill body.", + "type": "string", + "minLength": 1, + "maxLength": 50000 } }, "required": ["workspaceId"], "additionalProperties": false, - "title": "Update sandbox request", - "description": "Sandbox fields to change; at least one editable field is required.", + "title": "Update skill request", + "description": "Skill fields to change; at least one editable field is required.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + "description": "Updated refund guidance" } ] }, - "V2SandboxDeleteData": { + "V2SkillDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted sandbox." + "description": "Identifier of the deleted skill." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the sandbox was deleted." + "description": "Whether the skill was deleted." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete sandbox data", - "description": "Sandbox deletion acknowledgement." + "title": "Delete skill data", + "description": "Skill deletion acknowledgement." }, - "DeleteSandboxResponse": { + "DeleteSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SandboxDeleteData" + "$ref": "#/components/schemas/V2SkillDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete sandbox response", - "description": "Acknowledgement that the sandbox was deleted.", + "title": "Delete skill response", + "description": "Acknowledgement that the skill was deleted.", "examples": [ { "data": { @@ -7461,23 +7258,16 @@ } ] }, - "V2Credential": { + "V2SkillEditor": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique credential identifier." - }, - "type": { - "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." - }, - "displayName": { + "email": { "type": "string", - "description": "Credential display name." + "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." }, - "description": { + "name": { "anyOf": [ { "type": "string" @@ -7486,9 +7276,9 @@ "type": "null" } ], - "description": "Optional credential description." + "description": "Display name of the skill editor." }, - "providerId": { + "image": { "anyOf": [ { "type": "string" @@ -7497,64 +7287,25 @@ "type": "null" } ], - "description": "Integration provider authenticated by this credential." + "description": "Profile image URL of the skill editor." }, - "accountId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Linked account identifier for OAuth credentials." - }, - "hasServiceAccountKey": { + "isWorkspaceAdmin": { "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." - }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was last updated." + "description": "Whether editor access is derived from workspace administration." } }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], + "required": ["email", "name", "image", "isWorkspaceAdmin"], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "Skill editor", + "description": "Public identity fields for a user who can edit a skill." }, - "ListCredentialsResponse": { + "ListSkillEditorsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2SkillEditor" }, "description": "Items in the current page." }, @@ -7572,879 +7323,798 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "List skill editors response", + "description": "Public identity fields for users who can edit the skill.", "examples": [ { "data": [ { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false } ], "nextCursor": null } ] }, - "V2CredentialProvider": { - "oneOf": [ + "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": { + "id": { + "type": "string", + "description": "Unique custom tool identifier." + }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace." + }, + "schema": { "type": "object", "properties": { "type": { "type": "string", - "const": "oauth", - "description": "Browser-based OAuth connection method." - }, - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable credential-provider identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Credential provider display name." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Credential provider description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can connect the provider in the current deployment." - }, - "supportsReconnect": { - "type": "boolean", - "description": "Whether existing credentials for this service can be reconnected." + "const": "function", + "description": "Function declaration discriminator." }, - "authorizationOptions": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "object", - "properties": { - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider identifier accepted by the connection endpoint." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable authorization-server label." - } + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." }, - "required": ["providerId", "label"], - "additionalProperties": false - }, - "description": "Authorization servers available for this OAuth service." - }, - "fields": { - "maxItems": 20, - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact create-body field name." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable field label." - }, - "placeholder": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Suggested input placeholder." - }, - "required": { - "type": "boolean", - "description": "Whether the field is required for the selected flow." - }, - "secret": { - "type": "boolean", - "description": "Whether the submitted field is write-only secret material." - }, - "multiline": { - "type": "boolean", - "description": "Whether the field is intended for multi-line input." - }, - "requiredForAuthMethods": { - "description": "Authentication methods for which this field is required.", - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "options": { - "description": "Fixed values accepted by a selector field.", - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Submitted option value." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable option label." - } + "propertyNames": { + "type": "string" }, - "required": ["value", "label"], - "additionalProperties": false + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } } }, - "hint": { - "description": "Provider-specific setup guidance.", - "type": "string", - "minLength": 1, - "maxLength": 2000 - } - }, - "required": ["id", "label", "placeholder", "required", "secret", "multiline"], - "additionalProperties": false + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } }, - "description": "Write-only setup fields required before starting this OAuth flow." + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." } }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions", - "fields" - ], - "additionalProperties": false + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "description": "Tool implementation executed in the sandboxed function runtime." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was last updated." + } + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { "type": "object", "properties": { "type": { "type": "string", - "const": "service_account", - "description": "Direct service-account credential method." - }, - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable credential-provider identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Credential provider display name." + "const": "function", + "description": "Function declaration discriminator." }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Credential provider description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can connect the provider in the current deployment." - }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID accepted by credential creation." - }, - "docsUrl": { - "type": "string", - "format": "uri", - "description": "Setup guide for the provider." - }, - "helpText": { - "description": "Provider-specific setup guidance.", - "type": "string", - "minLength": 1, - "maxLength": 2000 - }, - "requiresClientGeneratedCredentialId": { - "type": "boolean", - "description": "Whether the caller must generate and submit the credential ID before setup." - }, - "fields": { - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact create-body field name." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable field label." - }, - "placeholder": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Suggested input placeholder." - }, - "required": { - "type": "boolean", - "description": "Whether the field is required for the selected flow." - }, - "secret": { - "type": "boolean", - "description": "Whether the submitted field is write-only secret material." - }, - "multiline": { - "type": "boolean", - "description": "Whether the field is intended for multi-line input." - }, - "requiredForAuthMethods": { - "description": "Authentication methods for which this field is required.", - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "options": { - "description": "Fixed values accepted by a selector field.", - "minItems": 1, - "maxItems": 20, - "type": "array", - "items": { + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Submitted option value." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable option label." - } + "propertyNames": { + "type": "string" }, - "required": ["value", "label"], - "additionalProperties": false + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } } }, - "hint": { - "description": "Provider-specific setup guidance.", - "type": "string", - "minLength": 1, - "maxLength": 2000 + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" } }, - "required": ["id", "label", "placeholder", "required", "secret", "multiline"], - "additionalProperties": false - }, - "description": "Create-body fields accepted by this provider. Secret fields are write-only." + "required": ["orderId"] + } } }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "providerId", - "docsUrl", - "requiresClientGeneratedCredentialId", - "fields" - ], - "additionalProperties": false + "code": "return { ok: true }" } - ], - "title": "Credential Provider", - "description": "An OAuth or service-account connection method available to a workspace." + ] }, - "ListCredentialProvidersResponse": { + "GetCustomToolResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2CredentialProvider" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List credential providers response", - "description": "OAuth and service-account connection methods.", + "title": "Get custom tool response", + "description": "One custom tool.", "examples": [ { - "data": [ - { - "type": "oauth", - "serviceId": "salesforce", - "name": "Salesforce", - "description": "Connect to Salesforce CRM data and operations.", - "providerFamily": "salesforce", - "available": true, - "supportsReconnect": true, - "fields": [], - "authorizationOptions": [ - { - "providerId": "salesforce", - "label": "Production" - }, - { - "providerId": "salesforce-sandbox", - "label": "Sandbox" - } - ] - }, - { - "type": "service_account", - "serviceId": "zoom-service-account", - "providerId": "zoom-service-account", - "name": "Zoom server-to-server app", - "description": "Connect Zoom with a server-to-server app.", - "providerFamily": "zoom", - "available": true, - "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", - "requiresClientGeneratedCredentialId": false, - "fields": [ - { - "id": "clientId", - "label": "Client ID", - "placeholder": "Paste the client ID", - "required": true, - "secret": false, - "multiline": false - }, - { - "id": "clientSecret", - "label": "Client secret", - "placeholder": "Paste the client secret", - "required": true, - "secret": true, - "multiline": false - }, - { - "id": "orgId", - "label": "Account ID", - "placeholder": "Paste the account ID", - "required": true, - "secret": false, - "multiline": false + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] } - ] - } - ], - "nextCursor": null + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } } ] }, - "CreateServiceAccountCredentialResponse": { + "UpdateCustomToolResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2CustomTool" } }, "required": ["data"], "additionalProperties": false, - "title": "Create service-account credential response", - "description": "Verified credential metadata without secret material.", + "title": "Update custom tool response", + "description": "The updated custom tool.", "examples": [ { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "CreateServiceAccountCredentialRequest": { + "UpdateCustomToolRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "type": { - "type": "string", - "const": "service_account", - "description": "Service-account credential discriminator." - }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID returned by provider discovery." + "description": "Workspace that owns the custom tool." }, - "displayName": { - "description": "Optional name; providers may derive one from the verified account identity.", + "title": { + "description": "New display title for the tool.", "type": "string", "minLength": 1, - "maxLength": 255 - }, - "description": { - "description": "Optional credential description.", - "type": "string", - "maxLength": 500 + "maxLength": 200 }, - "id": { - "description": "Required only when provider discovery requests a client-generated ID.", - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "schema": { + "description": "Replacement function declaration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + } }, - "credentials": { + "code": { + "description": "Replacement tool implementation.", "type": "string", - "minLength": 1, - "maxLength": 131072, - "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", - "writeOnly": true + "maxLength": 100000 } }, - "required": ["workspaceId", "type", "providerId", "credentials"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Create service-account credential request", - "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery." + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + ] }, - "V2CredentialConnectionAuthorization": { + "V2CustomToolDeleteData": { "type": "object", "properties": { - "authorizationUrl": { + "id": { "type": "string", - "format": "uri", - "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + "description": "Identifier of the deleted custom tool." }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the connection link expires." + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." } }, - "required": ["authorizationUrl", "expiresAt"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Credential Connection Authorization", - "description": "A short-lived browser entrypoint for an OAuth connection flow." + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." }, - "CreateCredentialConnectionResponse": { + "DeleteCustomToolResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + "$ref": "#/components/schemas/V2CustomToolDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Create credential connection response", - "description": "Short-lived Sim browser entrypoint and its expiry.", + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", "examples": [ { "data": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", - "expiresAt": "2026-06-20T14:17:11.000Z" + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true } } ] }, - "CreateCredentialConnectionBody": { - "anyOf": [ - { - "anyOf": [ - { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name shown for the new credential in Sim." - }, - "providerId": { - "type": "string", - "const": "quickbooks", - "description": "QuickBooks OAuth provider ID returned by credential-provider discovery." - }, - "oauthClientConfig": { - "type": "object", - "properties": { - "clientId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Client ID for the caller-managed Intuit OAuth application." - }, - "clientSecret": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Write-only client secret for the caller-managed Intuit OAuth application.", - "writeOnly": true - }, - "environment": { - "type": "string", - "enum": ["sandbox", "production"], - "description": "Intuit company environment used for authorization and API requests." - }, - "webhookVerifierToken": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Write-only verifier token for webhook signatures from the caller-managed app.", - "writeOnly": true - } - }, - "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], - "additionalProperties": false, - "description": "Write-only caller-managed Intuit OAuth app configuration." - } - }, - "required": ["workspaceId", "displayName", "providerId", "oauthClientConfig"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name shown for the new credential in Sim." - }, - "providerId": { - "type": "string", - "enum": [ - "github-repositories", - "google-email", - "google-drive", - "google-docs", - "google-sheets", - "google-forms", - "google-calendar", - "google-contacts", - "google-ads", - "google-bigquery", - "google-tasks", - "google-vault", - "google-groups", - "google-chat", - "google-meet", - "vertex-ai", - "microsoft-ad", - "microsoft-dataverse", - "microsoft-excel", - "microsoft-planner", - "microsoft-teams", - "microsoft-word", - "outlook", - "onedrive", - "sharepoint", - "x", - "tiktok", - "confluence", - "jira", - "airtable", - "bitbucket", - "notion", - "clickup", - "linear", - "manageengine-sdp", - "monday", - "box", - "dropbox", - "shopify", - "slack", - "reddit", - "wealthbox", - "webflow", - "trello", - "asana", - "attio", - "calcom", - "docusign", - "pipedrive", - "hubspot", - "linkedin", - "instagram", - "salesforce", - "salesforce-sandbox", - "zoho-desk", - "zoom", - "wordpress", - "spotify" - ], - "description": "Exact OAuth provider ID returned by credential-provider discovery." - } - }, - "required": ["workspaceId", "displayName", "providerId"], - "additionalProperties": false - } - ] - }, - { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace expected to own the credential." - }, - "credentialId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token." - }, - "oauthClientConfig": { - "description": "Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.", - "type": "object", - "properties": { - "clientId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Client ID for the caller-managed Intuit OAuth application." - }, - "clientSecret": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Write-only client secret for the caller-managed Intuit OAuth application.", - "writeOnly": true - }, - "environment": { - "type": "string", - "enum": ["sandbox", "production"], - "description": "Intuit company environment used for authorization and API requests." - }, - "webhookVerifierToken": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Write-only verifier token for webhook signatures from the caller-managed app.", - "writeOnly": true - } - }, - "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], - "additionalProperties": false - } - }, - "required": ["workspaceId", "credentialId"], - "additionalProperties": false - } - ], - "title": "Create credential connection body", - "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." - }, - "V2CredentialDeleteData": { + "V2Sandbox": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Disconnected credential identifier." + "description": "Unique sandbox identifier." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the credential was disconnected." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete credential data", - "description": "Credential disconnection acknowledgement." - }, - "DeleteCredentialResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Disconnect credential response", - "description": "Acknowledgement that the credential was disconnected.", - "examples": [ - { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true - } - } - ] - }, - "V2SecretWithValue": { - "type": "object", - "properties": { "name": { "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Display name, unique within the workspace." }, - "scope": { + "language": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." }, - "unredacted": { - "type": "boolean", - "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Package specifiers installed into the sandbox, one per entry." }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "cliTools": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + }, + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates." + }, + "systemPackages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Debian packages installed into the sandbox, one per entry." + }, + "buildStatus": { + "anyOf": [ + { + "type": "string", + "enum": ["pending", "building", "ready", "failed"] + }, + { + "type": "null" + } + ], + "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + }, + "errorCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Classified build failure code, or `null`." + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Human-readable build failure summary, or `null`." + }, + "errorDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Tail of the installer log for a failed build, or `null`." + }, + "builtAt": { + "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": "ISO 8601 timestamp when the current image finished building, or `null`." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was created." + "description": "ISO 8601 timestamp when the sandbox was created." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was last updated." - }, - "value": { - "description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.", - "type": "string" + "description": "ISO 8601 timestamp when the sandbox was last updated." } }, "required": [ + "id", "name", - "scope", - "description", - "unredacted", - "role", + "language", + "dependencies", + "cliTools", + "systemPackages", + "buildStatus", + "errorCode", + "errorMessage", + "errorDetail", + "builtAt", "createdAt", "updatedAt" ], "additionalProperties": false, - "title": "Secret metadata with visible value", - "description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)." + "title": "Sandbox", + "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." }, - "ListSecretsResponse": { + "ListSandboxesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2SecretWithValue" + "$ref": "#/components/schemas/V2Sandbox" }, "description": "Items in the current page." }, @@ -8462,408 +8132,355 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List secrets response", - "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", + "title": "List sandboxes response", + "description": "Sandboxes defined in the workspace.", "examples": [ { "data": [ { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "unredacted": false, - "role": "admin", + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" - }, - { - "name": "STAGING_BASE_URL", - "scope": "workspace", - "description": "Staging environment base URL.", - "unredacted": true, - "role": "member", - "createdAt": "2026-06-03T11:30:00.000Z", - "updatedAt": "2026-06-21T08:45:09.000Z", - "value": "https://staging.example.com" } ], "nextCursor": null } ] }, - "V2Secret": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." - }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." - }, - "unredacted": { - "type": "boolean", - "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." - }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was last updated." - } - }, - "required": [ - "name", - "scope", - "description", - "unredacted", - "role", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." - }, - "SetSecretResponse": { + "CreateSandboxResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2Sandbox" } }, "required": ["data"], "additionalProperties": false, - "title": "Set secret response", - "description": "Metadata for the created or replaced secret without its value.", + "title": "Create sandbox response", + "description": "The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "unredacted": false, - "role": "admin", + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "SetSecretRequest": { + "CreateSandboxRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + "description": "Workspace in which to create the sandbox." }, - "scope": { + "name": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "minLength": 1, + "maxLength": 64, + "description": "Display name, unique within the workspace; 1 to 64 characters." }, - "value": { - "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", - "writeOnly": true, + "language": { "type": "string", - "minLength": 1, - "maxLength": 65536 + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." }, - "description": { - "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 - }, - { - "type": "null" - } - ] + "dependencies": { + "default": [], + "description": "Package specifiers installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } }, - "unredacted": { - "description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.", - "type": "boolean" + "cliTools": { + "default": [], + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } + }, + "systemPackages": { + "default": [], + "description": "Debian packages installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } } }, - "required": ["workspaceId", "scope"], + "required": ["workspaceId", "name", "language"], "additionalProperties": false, - "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", + "title": "Create sandbox request", + "description": "Name, language, and dependency set of a new sandbox.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "value": "YOUR_SECRET_VALUE" - }, - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "unredacted": false + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "systemPackages": ["graphviz"] } ] }, - "V2SecretDeleteData": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." - }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the secret was deleted." - } - }, - "required": ["name", "scope", "deleted"], - "additionalProperties": false, - "title": "Delete secret data", - "description": "Secret deletion acknowledgement without the stored value." - }, - "DeleteSecretResponse": { + "GetSandboxResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SecretDeleteData" + "$ref": "#/components/schemas/V2Sandbox" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete secret response", - "description": "Acknowledgement that the secret was deleted.", + "title": "Get sandbox response", + "description": "One sandbox.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "deleted": true + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "V2Meta": { - "type": "object", - "properties": { - "v2Enabled": { - "type": "boolean", - "description": "Whether this API version is available. This is true when the endpoint is served." - }, - "keyType": { - "type": "string", - "enum": ["personal", "workspace", "oauth_access_token"], - "description": "Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted." - }, - "expiresAt": { - "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": "ISO 8601 timestamp when the calling credential expires, or null when it does not." - } - }, - "required": ["v2Enabled", "keyType", "expiresAt"], - "additionalProperties": false, - "title": "API capabilities", - "description": "API availability and lifecycle facts about the calling credential." - }, - "GetApiMetaResponse": { + "UpdateSandboxResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Meta" + "$ref": "#/components/schemas/V2Sandbox" } }, "required": ["data"], "additionalProperties": false, - "title": "API capabilities response", - "description": "API availability, credential type, and expiry for the caller.", + "title": "Update sandbox response", + "description": "The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.", "examples": [ { "data": { - "v2Enabled": true, - "keyType": "personal", - "expiresAt": null + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "WorkflowMcpServerListItem": { + "UpdateSandboxRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique workflow-MCP server identifier." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the sandbox." }, "name": { + "description": "New display name, unique within the workspace; 1 to 64 characters.", "type": "string", - "description": "Server display name, shown to connecting MCP clients." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional server description, or null when unset." - }, - "isPublic": { - "type": "boolean", - "description": "Whether the server answers MCP clients without a Sim API key." - }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to. Published here so callers never build it.", - "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + "minLength": 1, + "maxLength": 64 }, - "createdAt": { + "language": { + "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", "type": "string", - "description": "ISO 8601 timestamp when the server was created.", - "format": "date-time" + "enum": ["javascript", "python"] }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the server was last modified.", - "format": "date-time" + "dependencies": { + "description": "Replacement package list; replaces the whole list.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } }, - "toolCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of workflows published as tools." + "cliTools": { + "description": "Replacement managed CLI list; replaces the whole list.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } }, - "toolNames": { + "systemPackages": { + "description": "Replacement Debian package list; replaces the whole list.", + "maxItems": 1000, "type": "array", "items": { - "type": "string" - }, - "description": "Tool names this server publishes, alphabetically ordered." + "type": "string", + "maxLength": 2000 + } } }, - "required": [ - "id", - "name", - "description", - "isPublic", - "mcpServerUrl", - "createdAt", - "updatedAt", - "toolCount", - "toolNames" - ], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Workflow MCP server list item", - "description": "A published MCP server together with the tool names it exposes." + "title": "Update sandbox request", + "description": "Sandbox fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + } + ] }, - "ListWorkflowMcpServersResponse": { + "V2SandboxDeleteData": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowMcpServerListItem" - }, - "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." + "id": { + "type": "string", + "description": "Identifier of the deleted sandbox." }, - "toolNamesTruncated": { + "deleted": { "type": "boolean", - "description": "Whether the page-wide tool-name limit left some inventories incomplete. Use List Workflow MCP Tools for one server and check its `truncated` flag before treating the inventory as complete. `nextCursor` paginates servers, not tool names." + "const": true, + "description": "Whether the sandbox was deleted." } }, - "required": ["data", "nextCursor", "toolNamesTruncated"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "List workflow MCP servers response", - "description": "A cursor-paginated page of published MCP servers.", + "title": "Delete sandbox data", + "description": "Sandbox deletion acknowledgement." + }, + "DeleteSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SandboxDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete sandbox response", + "description": "Acknowledgement that the sandbox was deleted.", "examples": [ { - "data": [ - { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "name": "Support agents", - "description": "Ticket triage and escalation workflows.", - "isPublic": false, - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z", - "toolCount": 1, - "toolNames": ["triage_ticket"] - } - ], - "nextCursor": null, - "toolNamesTruncated": false + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } } ] }, - "WorkflowMcpServer": { + "V2Credential": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique workflow-MCP server identifier." + "description": "Unique credential identifier." }, - "name": { + "type": { "type": "string", - "description": "Server display name, shown to connecting MCP clients." + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." }, "description": { "anyOf": [ @@ -8874,211 +8491,2215 @@ "type": "null" } ], - "description": "Optional server description, or null when unset." + "description": "Optional credential description." }, - "isPublic": { + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { "type": "boolean", - "description": "Whether the server answers MCP clients without a Sim API key." + "description": "Whether a service-account payload is stored. Its contents are never returned." }, - "mcpServerUrl": { + "role": { "type": "string", - "description": "Endpoint an MCP client connects to. Published here so callers never build it.", - "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + "enum": ["admin", "member"], + "description": "Caller role for the credential." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was created.", - "format": "date-time" + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was created." }, "updatedAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was last modified.", - "format": "date-time" + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was last updated." } }, "required": [ "id", - "name", + "type", + "displayName", "description", - "isPublic", - "mcpServerUrl", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", "createdAt", "updatedAt" ], "additionalProperties": false, - "title": "Workflow MCP server", - "description": "A workspace-published MCP server exposing deployed workflows as tools." + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." }, - "CreateWorkflowMcpServerResponse": { + "ListCredentialsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Create workflow MCP server response", - "description": "The published MCP server.", + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", "examples": [ { - "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "name": "Support agents", - "description": "Ticket triage and escalation workflows.", - "isPublic": false, - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null } ] }, - "CreateWorkflowMcpServerRequest": { - "type": "object", - "properties": { + "V2CredentialProvider": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + }, + "fields": { + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Write-only setup fields required before starting this OAuth flow." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions", + "fields" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", + "examples": [ + { + "data": [ + { + "type": "oauth", + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "fields": [], + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] + } + ], + "nextCursor": null + } + ] + }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "credentials": { + "type": "string", + "minLength": 1, + "maxLength": 131072, + "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", + "writeOnly": true + } + }, + "required": ["workspaceId", "type", "providerId", "credentials"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery." + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + }, + "providerId": { + "type": "string", + "const": "quickbooks", + "description": "QuickBooks OAuth provider ID returned by credential-provider discovery." + }, + "oauthClientConfig": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Client ID for the caller-managed Intuit OAuth application." + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only client secret for the caller-managed Intuit OAuth application.", + "writeOnly": true + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "Intuit company environment used for authorization and API requests." + }, + "webhookVerifierToken": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only verifier token for webhook signatures from the caller-managed app.", + "writeOnly": true + } + }, + "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], + "additionalProperties": false, + "description": "Write-only caller-managed Intuit OAuth app configuration." + } + }, + "required": ["workspaceId", "displayName", "providerId", "oauthClientConfig"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + }, + "providerId": { + "type": "string", + "enum": [ + "github-repositories", + "google-email", + "google-drive", + "google-docs", + "google-sheets", + "google-forms", + "google-calendar", + "google-contacts", + "google-ads", + "google-bigquery", + "google-tasks", + "google-vault", + "google-groups", + "google-chat", + "google-meet", + "vertex-ai", + "microsoft-ad", + "microsoft-dataverse", + "microsoft-excel", + "microsoft-planner", + "microsoft-teams", + "microsoft-word", + "outlook", + "onedrive", + "sharepoint", + "x", + "tiktok", + "confluence", + "jira", + "airtable", + "bitbucket", + "notion", + "clickup", + "linear", + "manageengine-sdp", + "monday", + "box", + "dropbox", + "shopify", + "slack", + "reddit", + "wealthbox", + "webflow", + "trello", + "asana", + "attio", + "calcom", + "docusign", + "pipedrive", + "hubspot", + "linkedin", + "instagram", + "salesforce", + "salesforce-sandbox", + "zoho-desk", + "zoom", + "wordpress", + "spotify" + ], + "description": "Exact OAuth provider ID returned by credential-provider discovery." + } + }, + "required": ["workspaceId", "displayName", "providerId"], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place. QuickBooks reconnects also require oauthClientConfig with the Intuit client ID, client secret, environment, and webhook verifier token." + }, + "oauthClientConfig": { + "description": "Write-only Intuit OAuth app configuration. Required when credentialId identifies a QuickBooks credential; omit it for other providers.", + "type": "object", + "properties": { + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Client ID for the caller-managed Intuit OAuth application." + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only client secret for the caller-managed Intuit OAuth application.", + "writeOnly": true + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "Intuit company environment used for authorization and API requests." + }, + "webhookVerifierToken": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Write-only verifier token for webhook signatures from the caller-managed app.", + "writeOnly": true + } + }, + "required": ["clientId", "clientSecret", "environment", "webhookVerifierToken"], + "additionalProperties": false + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, + "V2SecretWithValue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + }, + "unredacted": { + "type": "boolean", + "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + }, + "value": { + "description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.", + "type": "string" + } + }, + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Secret metadata with visible value", + "description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)." + }, + "ListSecretsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2SecretWithValue" + }, + "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 secrets response", + "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", + "examples": [ + { + "data": [ + { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "unredacted": false, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + }, + { + "name": "STAGING_BASE_URL", + "scope": "workspace", + "description": "Staging environment base URL.", + "unredacted": true, + "role": "member", + "createdAt": "2026-06-03T11:30:00.000Z", + "updatedAt": "2026-06-21T08:45:09.000Z", + "value": "https://staging.example.com" + } + ], + "nextCursor": null + } + ] + }, + "V2Secret": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + }, + "unredacted": { + "type": "boolean", + "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + } + }, + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." + }, + "SetSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Secret" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Set secret response", + "description": "Metadata for the created or replaced secret without its value.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "unredacted": false, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "SetSecretRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "unredacted": { + "description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.", + "type": "boolean" + } + }, + "required": ["workspaceId", "scope"], + "additionalProperties": false, + "title": "Set secret request", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false + } + ] + }, + "V2SecretDeleteData": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the secret was deleted." + } + }, + "required": ["name", "scope", "deleted"], + "additionalProperties": false, + "title": "Delete secret data", + "description": "Secret deletion acknowledgement without the stored value." + }, + "DeleteSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SecretDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete secret response", + "description": "Acknowledgement that the secret was deleted.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "deleted": true + } + } + ] + }, + "V2Meta": { + "type": "object", + "properties": { + "v2Enabled": { + "type": "boolean", + "description": "Whether this API version is available. This is true when the endpoint is served." + }, + "keyType": { + "type": "string", + "enum": ["personal", "workspace", "oauth_access_token"], + "description": "Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted." + }, + "expiresAt": { + "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": "ISO 8601 timestamp when the calling credential expires, or null when it does not." + } + }, + "required": ["v2Enabled", "keyType", "expiresAt"], + "additionalProperties": false, + "title": "API capabilities", + "description": "API availability and lifecycle facts about the calling credential." + }, + "GetApiMetaResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Meta" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "API capabilities response", + "description": "API availability, credential type, and expiry for the caller.", + "examples": [ + { + "data": { + "v2Enabled": true, + "keyType": "personal", + "expiresAt": null + } + } + ] + }, + "WorkflowMcpServerListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + }, + "toolCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of workflows published as tools." + }, + "toolNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names this server publishes, alphabetically ordered." + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt", + "toolCount", + "toolNames" + ], + "additionalProperties": false, + "title": "Workflow MCP server list item", + "description": "A published MCP server together with the tool names it exposes." + }, + "ListWorkflowMcpServersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpServerListItem" + }, + "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." + }, + "toolNamesTruncated": { + "type": "boolean", + "description": "Whether the page-wide tool-name limit left some inventories incomplete. Use List Workflow MCP Tools for one server and check its `truncated` flag before treating the inventory as complete. `nextCursor` paginates servers, not tool names." + } + }, + "required": ["data", "nextCursor", "toolNamesTruncated"], + "additionalProperties": false, + "title": "List workflow MCP servers response", + "description": "A cursor-paginated page of published MCP servers.", + "examples": [ + { + "data": [ + { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z", + "toolCount": 1, + "toolNames": ["triage_ticket"] + } + ], + "nextCursor": null, + "toolNamesTruncated": false + } + ] + }, + "WorkflowMcpServer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP server", + "description": "A workspace-published MCP server exposing deployed workflows as tools." + }, + "CreateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow MCP server response", + "description": "The published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "CreateWorkflowMcpServerRequest": { + "type": "object", + "properties": { "workspaceId": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to publish the server." + "maxLength": 128, + "description": "Workspace in which to publish the server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL.", + "default": false, + "type": "boolean" + }, + "workflowIds": { + "description": "Deployed workflows to publish as tools on the new server.", + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow MCP server request", + "description": "A new workspace-published MCP server and the workflows it exposes.", + "examples": [ + { + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "name": "Support agents", + "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + ] + }, + "GetWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get workflow MCP server response", + "description": "A single published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "WorkflowMcpToolListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool list item", + "description": "A tool a server publishes, as returned by a read." + }, + "ListWorkflowMcpToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpToolListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + }, + "truncated": { + "type": "boolean", + "description": "Whether the tool limit left this inventory incomplete. The list is unpaginated and `nextCursor` remains null even when truncated. Do not treat a truncated inventory as the complete set of published tools." + } + }, + "required": ["data", "nextCursor", "truncated"], + "additionalProperties": false, + "title": "List workflow MCP tools response", + "description": "The tools a published MCP server exposes.", + "examples": [ + { + "data": [ + { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + ], + "nextCursor": null, + "truncated": false + } + ] + }, + "UpdateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow MCP server response", + "description": "The updated MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": true, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "UpdateWorkflowMcpServerRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "New server description, or null to clear it.", + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Update workflow MCP server request", + "description": "Merge-patch body for a published MCP server.", + "examples": [ + { + "isPublic": true + } + ] + }, + "DeleteWorkflowMcpServerResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the unpublished server." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the server was unpublished." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete workflow MCP server result", + "description": "Unpublish acknowledgement." + }, + "DeleteWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow MCP server response", + "description": "Acknowledgement that the MCP server was unpublished.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true + } + } + ] + }, + "WorkflowMcpTool": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "updated": { + "type": "boolean", + "description": "False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "updated", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool", + "description": "A deployed workflow published as a tool on a workflow-MCP server." + }, + "DeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool response", + "description": "The published tool.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "updated": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "DeployWorkflowMcpToolRequest": { + "type": "object", + "properties": { + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Deployed workflow to publish. The workflow must already be deployed." + }, + "toolName": { + "description": "Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "toolDescription": { + "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", + "type": "string", + "maxLength": 2000 + }, + "parameterDescriptions": { + "description": "Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.", + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Input field of the deployed workflow to describe." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "Text MCP clients see for that field." + } + }, + "required": ["name", "description"], + "additionalProperties": false + } + } + }, + "required": ["workflowId"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool request", + "description": "The workflow to publish and the tool metadata MCP clients see.", + "examples": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket" + } + ] + }, + "UndeployWorkflowMcpToolResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the removed tool." + }, + "serverId": { + "type": "string", + "description": "Server the tool was removed from." + }, + "workflowId": { + "type": "string", + "description": "Workflow that is no longer published." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the tool was removed." + } + }, + "required": ["id", "serverId", "workflowId", "deleted"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool result", + "description": "Tool removal acknowledgement." + }, + "UndeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool response", + "description": "Acknowledgement that the tool was removed.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + ] + }, + "UpdateCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update credential response", + "description": "Updated credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCredentialRequest": { + "type": "object", + "properties": { + "displayName": { + "description": "New name shown for the credential in Sim.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "New credential description. Send null to clear the stored one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 }, - "name": { + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Server display name, shown to connecting MCP clients." + "maxLength": 8192 }, - "description": { - "description": "Optional server description.", + "domain": { + "description": "Provider account domain.", "type": "string", - "maxLength": 2000 + "minLength": 1, + "maxLength": 2048 }, - "isPublic": { - "description": "Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL.", - "default": false, - "type": "boolean" + "atlassianProduct": { + "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", + "type": "string", + "enum": ["jira", "confluence"] }, - "workflowIds": { - "description": "Deployed workflows to publish as tools on the new server.", - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "required": ["workspaceId", "name"], - "additionalProperties": false, - "title": "Create workflow MCP server request", - "description": "A new workspace-published MCP server and the workflows it exposes.", - "examples": [ - { - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "name": "Support agents", - "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - } - ] - }, - "GetWorkflowMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 } }, - "required": ["data"], "additionalProperties": false, - "title": "Get workflow MCP server response", - "description": "A single published MCP server.", + "title": "Update credential request", + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", "examples": [ { - "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "name": "Support agents", - "description": "Ticket triage and escalation workflows.", - "isPublic": false, - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } + "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" } ] }, - "WorkflowMcpToolListItem": { + "V2BlockSummary": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique tool identifier." + "description": "Block type identifier, used as a workflow block’s `type`." }, - "serverId": { + "name": { "type": "string", - "description": "Server that publishes this tool." + "description": "Display name." }, - "workflowId": { + "description": { "type": "string", - "description": "Workflow this tool executes." + "description": "One-line summary of what the block does." }, - "toolName": { + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { "type": "string", - "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." }, - "toolDescription": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Description shown to MCP clients." + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" }, - "mcpServerUrl": { + "source": { "type": "string", - "description": "Endpoint an MCP client connects to." + "enum": ["builtin", "custom"], + "description": "Block source: `builtin` for built-in blocks, or `custom` for workflows this workspace deployed as blocks." }, - "apiEndpoint": { - "type": "string", - "description": "Sim execution endpoint this tool calls through." + "authMode": { + "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", + "type": "string" }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" + "triggerAllowed": { + "type": "boolean", + "description": "Whether the block declares itself usable as a trigger." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "createdAt", - "updatedAt" + "triggerCapable": { + "type": "boolean", + "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the triggers this block supports." + }, + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." + }, + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on the block read." + }, + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." + }, + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + }, + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + }, + "docsLink": { + "description": "Sim documentation page for the integration.", + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." + } + }, + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags" ], "additionalProperties": false, - "title": "Workflow MCP tool list item", - "description": "A tool a server publishes, as returned by a read." + "title": "Block summary", + "description": "List view of a block: what it is and what it references, by id." }, - "ListWorkflowMcpToolsResponse": { + "ListBlocksResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowMcpToolListItem" + "$ref": "#/components/schemas/V2BlockSummary" }, "description": "Items in the current page." }, @@ -9091,488 +10712,560 @@ "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - }, - "truncated": { - "type": "boolean", - "description": "Whether the tool limit left this inventory incomplete. The list is unpaginated and `nextCursor` remains null even when truncated. Do not treat a truncated inventory as the complete set of published tools." + "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", "truncated"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List workflow MCP tools response", - "description": "The tools a published MCP server exposes.", + "title": "List blocks response", + "description": "Blocks available in the workspace.", "examples": [ { "data": [ { - "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", - "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "toolName": "triage_ticket", - "toolDescription": "Execute Ticket triage workflow", - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"] } ], - "nextCursor": null, - "truncated": false - } - ] - }, - "UpdateWorkflowMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update workflow MCP server response", - "description": "The updated MCP server.", - "examples": [ - { - "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "name": "Support agents", - "description": "Ticket triage and escalation workflows.", - "isPublic": true, - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } + "nextCursor": null } ] }, - "UpdateWorkflowMcpServerRequest": { + "V2BlockField": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name, shown to connecting MCP clients." + "description": "Field identifier, and the key its value is stored under." + }, + "type": { + "type": "string", + "description": "Editor control the field renders as, e.g. `short-input`." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "type": "boolean" + }, + "requiredWhen": { + "description": "Condition under which the field is required.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "mode": { + "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", + "type": "string" + }, + "hidden": { + "description": "Whether the field is hidden in the editor.", + "type": "boolean" + }, + "condition": { + "description": "Condition under which the field applies at all.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "options": { + "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "description": "Human-readable option label.", + "type": "string" + }, + "hasIcon": { + "description": "Whether the option renders with an icon. The icon itself is not published.", + "type": "boolean" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "min": { + "description": "Minimum accepted numeric value.", + "type": "number" + }, + "max": { + "description": "Maximum accepted numeric value.", + "type": "number" + }, + "step": { + "description": "Increment for numeric controls.", + "type": "number" + }, + "integer": { + "description": "Whether the numeric value must be a whole number.", + "type": "boolean" + }, + "rows": { + "description": "Visible row count for multi-line text.", + "type": "number" + }, + "password": { + "description": "Whether the stored value is masked in the editor.", + "type": "boolean" + }, + "multiSelect": { + "description": "Whether more than one option may be selected.", + "type": "boolean" + }, + "language": { + "description": "Language of a code field.", + "type": "string" + }, + "generationType": { + "description": "Kind of content AI assistance generates here.", + "type": "string" + }, + "serviceId": { + "description": "OAuth service this credential field authenticates.", + "type": "string" + }, + "requiredScopes": { + "description": "OAuth scopes the credential selected here must carry.", + "type": "array", + "items": { + "type": "string" + } + }, + "mimeType": { + "description": "MIME type filter applied to a file picker.", + "type": "string" + }, + "acceptedTypes": { + "description": "Accepted file extensions for an upload field.", + "type": "string" + }, + "multiple": { + "description": "Whether more than one file may be supplied.", + "type": "boolean" + }, + "maxSize": { + "description": "Maximum upload size in megabytes.", + "type": "number" + }, + "connectionDroppable": { + "description": "Whether another block’s output can be dropped onto this field.", + "type": "boolean" + }, + "columns": { + "description": "Column headings for a table field.", + "type": "array", + "items": { + "type": "string" + } + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] }, - "description": { - "description": "New server description, or null to clear it.", + "canonicalParamId": { + "description": "Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.", + "type": "string" + }, + "defaultValue": { + "description": "Value used when the field is left unset.", "anyOf": [ { - "type": "string", - "maxLength": 2000 + "type": "string" }, { - "type": "null" + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Member of an object-valued default. Shape varies by field type." + } + }, + { + "type": "array", + "items": { + "description": "Element of an array-valued default. Shape varies by field type." + } } ] }, - "isPublic": { - "description": "Whether the server answers MCP clients without a Sim API key.", + "hasComputedDefault": { + "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", "type": "boolean" } }, + "required": ["id", "type"], "additionalProperties": false, - "title": "Update workflow MCP server request", - "description": "Merge-patch body for a published MCP server.", - "examples": [ - { - "isPublic": true - } - ] - }, - "DeleteWorkflowMcpServerResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the unpublished server." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the server was unpublished." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete workflow MCP server result", - "description": "Unpublish acknowledgement." - }, - "DeleteWorkflowMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete workflow MCP server response", - "description": "Acknowledgement that the MCP server was unpublished.", - "examples": [ - { - "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "deleted": true - } - } - ] + "title": "Block field", + "description": "One configuration field on a block." }, - "WorkflowMcpTool": { + "V2CatalogCondition": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique tool identifier." - }, - "serverId": { - "type": "string", - "description": "Server that publishes this tool." - }, - "workflowId": { - "type": "string", - "description": "Workflow this tool executes." - }, - "toolName": { + "field": { "type": "string", - "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + "description": "Sibling field id whose value decides this condition." }, - "toolDescription": { + "value": { "anyOf": [ { "type": "string" }, { - "type": "null" + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } } ], - "description": "Description shown to MCP clients." - }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to." - }, - "apiEndpoint": { - "type": "string", - "description": "Sim execution endpoint this tool calls through." - }, - "updated": { - "type": "boolean", - "description": "False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "updated", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Workflow MCP tool", - "description": "A deployed workflow published as a tool on a workflow-MCP server." - }, - "DeployWorkflowMcpToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Publish workflow as MCP tool response", - "description": "The published tool.", - "examples": [ - { - "data": { - "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", - "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "toolName": "triage_ticket", - "toolDescription": "Execute Ticket triage workflow", - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", - "updated": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - } - ] - }, - "DeployWorkflowMcpToolRequest": { - "type": "object", - "properties": { - "workflowId": { - "type": "string", - "minLength": 1, - "description": "Deployed workflow to publish. The workflow must already be deployed." - }, - "toolName": { - "description": "Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.", - "type": "string", - "minLength": 1, - "maxLength": 128 + "description": "Value, or set of accepted values, the named field must hold." }, - "toolDescription": { - "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", - "type": "string", - "maxLength": 2000 + "not": { + "description": "Invert the match: every value EXCEPT `value`.", + "type": "boolean" }, - "parameterDescriptions": { - "description": "Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.", - "maxItems": 100, - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Input field of the deployed workflow to describe." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "description": "Text MCP clients see for that field." - } + "and": { + "description": "A second clause that must hold as well.", + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Sibling field id for the second clause." + }, + "value": { + "description": "Value the second clause matches. Absent means \"holds any value\".", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + ] }, - "required": ["name", "description"], - "additionalProperties": false - } + "not": { + "description": "Invert the second clause.", + "type": "boolean" + } + }, + "required": ["field"], + "additionalProperties": false } }, - "required": ["workflowId"], + "required": ["field", "value"], "additionalProperties": false, - "title": "Publish workflow as MCP tool request", - "description": "The workflow to publish and the tool metadata MCP clients see.", - "examples": [ - { - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "toolName": "triage_ticket" - } - ] + "title": "Catalog condition", + "description": "When a configuration field applies, expressed against a sibling field." }, - "UndeployWorkflowMcpToolResult": { + "V2OperationInput": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "description": "Identifier of the removed tool." + "description": "Value type." }, - "serverId": { - "type": "string", - "description": "Server the tool was removed from." + "required": { + "description": "Whether the value must be supplied.", + "type": "boolean" }, - "workflowId": { - "type": "string", - "description": "Workflow that is no longer published." + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the tool was removed." - } - }, - "required": ["id", "serverId", "workflowId", "deleted"], - "additionalProperties": false, - "title": "Unpublish workflow MCP tool result", - "description": "Tool removal acknowledgement." - }, - "UndeployWorkflowMcpToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Unpublish workflow MCP tool response", - "description": "Acknowledgement that the tool was removed.", - "examples": [ - { - "data": { - "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", - "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true - } - } - ] - }, - "UpdateCredentialResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "description": { + "description": "What the value means.", + "type": "string" + }, + "default": { + "description": "Value used when this input is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints declared by the tool parameter." + }, + "schema": { + "description": "JSON-Schema-shaped structure declared by the block input." } }, - "required": ["data"], + "required": ["type"], "additionalProperties": false, - "title": "Update credential response", - "description": "Updated credential metadata without secret material.", - "examples": [ - { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] + "title": "Operation input", + "description": "One value a block operation needs, from its tool or its block-level inputs." }, - "UpdateCredentialRequest": { + "V2ToolOutput": { "type": "object", "properties": { - "displayName": { - "description": "New name shown for the credential in Sim.", + "type": { "type": "string", - "minLength": 1, - "maxLength": 255 + "description": "Value type of the output field." }, "description": { - "description": "New credential description. Send null to clear the stored one.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 - }, - { - "type": "null" - } - ] - }, - "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 + "description": "What the field holds.", + "type": "string" }, - "domain": { - "description": "Provider account domain.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "optional": { + "description": "Whether the field may be absent.", + "type": "boolean" }, - "atlassianProduct": { - "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", - "type": "string", - "enum": ["jira", "confluence"] + "nullable": { + "description": "Whether the field may be null.", + "type": "boolean" }, - "signingSecret": { - "description": "Write-only webhook signing secret.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 + "properties": { + "description": "Members of an object-typed output, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } }, - "botToken": { - "description": "Write-only bot token.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 + "items": { + "description": "Element shape of an array-typed output.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Element value type." + }, + "description": { + "description": "What an element holds.", + "type": "string" + }, + "properties": { + "description": "Members of an object-typed element, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } + } + }, + "required": ["type"], + "additionalProperties": false }, - "clientId": { - "description": "OAuth client identifier.", + "fileConfig": { + "description": "File metadata for a file-typed output.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type of the produced file.", + "type": "string" + }, + "extension": { + "description": "File extension of the produced file.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool output", + "description": "One declared output field of a built-in tool." + }, + "V2ToolDetail": { + "type": "object", + "properties": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 512 + "description": "Registered tool identifier, including its version suffix." }, - "clientSecret": { - "description": "Write-only OAuth client secret.", - "writeOnly": true, + "name": { "type": "string", - "minLength": 1, - "maxLength": 1024 + "description": "Display name." }, - "certificateId": { - "description": "Provider certificate mapping identifier.", + "description": { "type": "string", - "minLength": 1, - "maxLength": 512 + "description": "What the tool does." }, - "orgId": { - "description": "Provider organization ID.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "version": { + "description": "Tool version.", + "type": "string" }, - "dataCenter": { - "description": "Provider data center.", + "hostedApiKey": { "type": "string", - "minLength": 1, - "maxLength": 32 + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." }, - "authMethod": { - "description": "Provider authentication method.", - "type": "string", - "minLength": 1, - "maxLength": 64 + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["required", "provider"], + "additionalProperties": false }, - "privateKey": { - "description": "Write-only PEM private key.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolParam" + }, + "description": "Parameters the tool accepts." }, - "username": { - "description": "Provider run-as username.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the tool produces." } }, + "required": ["id", "name", "description", "hostedApiKey", "params", "outputs"], "additionalProperties": false, - "title": "Update credential request", - "description": "Replacement display metadata and the write-only fields declared by provider discovery.", - "examples": [ - { - "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" + "title": "Tool", + "description": "A built-in tool with its declared parameters and outputs." + }, + "V2ToolParam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parameter value type." + }, + "required": { + "description": "Whether the parameter must be supplied.", + "type": "boolean" + }, + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" + }, + "description": { + "description": "What the parameter means.", + "type": "string" + }, + "default": { + "description": "Value used when the parameter is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints for structured params." } - ] + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool parameter", + "description": "One declared parameter of a built-in tool." }, - "V2BlockSummary": { + "V2BlockDetail": { "type": "object", "properties": { "id": { @@ -9621,1635 +11314,2576 @@ "items": { "type": "string" }, - "description": "Identifiers of the triggers this block supports." - }, - "toolIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Built-in tools this block can run. Read a tool by its id for the full definition." - }, - "operationIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Operations this block exposes. Their fields and tools are on the block read." - }, - "preview": { - "type": "boolean", - "description": "Whether the block is unreleased and revealed only to this caller." - }, - "sunset": { - "description": "Post-release lifecycle state. Absent for a block in normal support.", - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["legacy", "deprecated"], - "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." - }, - "replacedBy": { - "description": "Block type to migrate to, when one exists.", - "type": "string" - } - }, - "required": ["status"], - "additionalProperties": false - }, - "docsLink": { - "description": "Sim documentation page for the integration.", - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Catalog tags, e.g. `messaging`, `version-control`." - } - }, - "required": [ - "id", - "name", - "description", - "category", - "source", - "triggerAllowed", - "triggerCapable", - "triggerIds", - "toolIds", - "operationIds", - "preview", - "tags" - ], - "additionalProperties": false, - "title": "Block summary", - "description": "List view of a block: what it is and what it references, by id." - }, - "ListBlocksResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2BlockSummary" - }, - "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 blocks response", - "description": "Blocks available in the workspace.", - "examples": [ - { - "data": [ - { - "id": "slack", - "name": "Slack", - "description": "Send messages and read channels in Slack.", - "category": "tools", - "integrationType": "communication", - "source": "builtin", - "authMode": "oauth", - "triggerAllowed": true, - "triggerCapable": true, - "triggerIds": ["slack_webhook"], - "toolIds": ["slack_message", "slack_canvas_read"], - "operationIds": ["send", "read"], - "preview": false, - "docsLink": "https://docs.sim.ai/tools/slack", - "tags": ["messaging"] - } - ], - "nextCursor": null - } - ] - }, - "V2BlockField": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Field identifier, and the key its value is stored under." - }, - "type": { - "type": "string", - "description": "Editor control the field renders as, e.g. `short-input`." + "description": "Identifiers of the triggers this block supports." }, - "title": { - "description": "Human-readable label.", - "type": "string" + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, - "required": { - "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", - "type": "boolean" + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on the block read." }, - "requiredWhen": { - "description": "Condition under which the field is required.", - "$ref": "#/components/schemas/V2CatalogCondition" + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." }, - "description": { - "description": "Authored explanation of the field.", - "type": "string" + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + }, + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false }, - "placeholder": { - "description": "Placeholder shown in the editor.", + "docsLink": { + "description": "Sim documentation page for the integration.", "type": "string" }, - "mode": { - "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." + }, + "bestPractices": { + "description": "Authored guidance on using the block correctly.", "type": "string" }, - "hidden": { - "description": "Whether the field is hidden in the editor.", - "type": "boolean" + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that apply regardless of the selected operation." }, - "condition": { - "description": "Condition under which the field applies at all.", - "$ref": "#/components/schemas/V2CatalogCondition" + "operationInputSchema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + } + }, + "description": "Configuration fields keyed by the operation that reveals them." + }, + "inputDefinitions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`." + }, + "description": { + "description": "What the input means.", + "type": "string" + }, + "schema": { + "description": "JSON-Schema-shaped structure for object and array inputs." + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Block-level input definitions, keyed by parameter name." + }, + "operations": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "toolId": { + "description": "Built-in tool that performs this operation.", + "type": "string" + }, + "toolName": { + "description": "Display name of that tool.", + "type": "string" + }, + "description": { + "description": "What the operation does.", + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2OperationInput" + }, + "description": "Values this operation needs, excluding the ones the block supplies from its own block-level inputs." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the operation produces." + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that appear when this operation is selected." + } + }, + "required": ["inputs", "outputs", "inputSchema"], + "additionalProperties": false + }, + "description": "Operations the block exposes, keyed by operation id." + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolDetail" + }, + "description": "Every built-in tool the block can run, with parameters and outputs." + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Trigger identifier." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Top-level fields the trigger event delivers." + }, + "configFields": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Editor control the field renders as." + }, + "required": { + "type": "boolean", + "description": "Whether a value must be supplied." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "default": { + "description": "Value used when the field is left unset." + }, + "options": { + "description": "Selectable options.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "condition": { + "description": "Condition under which the field applies.", + "$ref": "#/components/schemas/V2CatalogCondition" + } + }, + "required": ["type", "required"], + "additionalProperties": false + }, + "description": "Fields that configure the trigger, keyed by field id." + } + }, + "required": ["id", "outputs", "configFields"], + "additionalProperties": false + }, + "description": "Triggers the block can run on." }, - "options": { - "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", - "type": "array", - "items": { + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "description": "Value stored when this option is selected." + "description": "Value type of the output." }, - "label": { - "description": "Human-readable option label.", + "description": { + "description": "What the output holds.", "type": "string" - }, - "hasIcon": { - "description": "Whether the option renders with an icon. The icon itself is not published.", - "type": "boolean" } }, - "required": ["id"], + "required": ["type"], "additionalProperties": false - } - }, - "min": { - "description": "Minimum accepted numeric value.", - "type": "number" - }, - "max": { - "description": "Maximum accepted numeric value.", - "type": "number" - }, - "step": { - "description": "Increment for numeric controls.", - "type": "number" - }, - "integer": { - "description": "Whether the numeric value must be a whole number.", - "type": "boolean" - }, - "rows": { - "description": "Visible row count for multi-line text.", - "type": "number" - }, - "password": { - "description": "Whether the stored value is masked in the editor.", - "type": "boolean" - }, - "multiSelect": { - "description": "Whether more than one option may be selected.", - "type": "boolean" - }, - "language": { - "description": "Language of a code field.", - "type": "string" - }, - "generationType": { - "description": "Kind of content AI assistance generates here.", - "type": "string" - }, - "serviceId": { - "description": "OAuth service this credential field authenticates.", - "type": "string" - }, - "requiredScopes": { - "description": "OAuth scopes the credential selected here must carry.", - "type": "array", - "items": { - "type": "string" - } - }, - "mimeType": { - "description": "MIME type filter applied to a file picker.", - "type": "string" - }, - "acceptedTypes": { - "description": "Accepted file extensions for an upload field.", - "type": "string" - }, - "multiple": { - "description": "Whether more than one file may be supplied.", - "type": "boolean" - }, - "maxSize": { - "description": "Maximum upload size in megabytes.", - "type": "number" - }, - "connectionDroppable": { - "description": "Whether another block’s output can be dropped onto this field.", - "type": "boolean" - }, - "columns": { - "description": "Column headings for a table field.", - "type": "array", - "items": { - "type": "string" - } - }, - "dependsOn": { - "description": "Sibling fields this field is cleared by when they change.", - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" + }, + "description": "Fields the block produces." + } + }, + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags", + "inputSchema", + "operationInputSchema", + "inputDefinitions", + "operations", + "tools", + "triggers", + "outputs" + ], + "additionalProperties": false, + "title": "Block", + "description": "A block with its configuration fields, operations, tools, and triggers." + }, + "GetBlockResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2BlockDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get block response", + "description": "One block with its fields, operations, tools, and triggers.", + "examples": [ + { + "data": { + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"], + "inputSchema": [ + { + "id": "operation", + "type": "dropdown", + "title": "Operation", + "required": true, + "options": [ + { + "id": "send", + "label": "Send message" + }, + { + "id": "read", + "label": "Read messages" + } + ] + } + ], + "operationInputSchema": { + "send": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + }, + "inputDefinitions": { + "channel": { + "type": "string", + "description": "Channel to post into." + } + }, + "operations": { + "send": { + "toolId": "slack_message", + "toolName": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "inputs": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + }, + "inputSchema": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + } + }, + "tools": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } } - }, - { - "type": "object", - "properties": { - "all": { - "description": "Every listed field must hold a value.", - "type": "array", - "items": { - "type": "string" + ], + "triggers": [ + { + "id": "slack_webhook", + "outputs": { + "text": { + "type": "string", + "description": "Message text." } }, - "any": { - "description": "At least one listed field must hold a value.", - "type": "array", - "items": { - "type": "string" + "configFields": { + "channels": { + "type": "short-input", + "required": false, + "title": "Channels" } } - }, - "additionalProperties": false + } + ], + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } } - ] + } + } + ] + }, + "V2ToolSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." }, - "canonicalParamId": { - "description": "Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.", + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", "type": "string" }, - "defaultValue": { - "description": "Value used when the field is left unset.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Member of an object-valued default. Shape varies by field type." - } + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." }, - { + "requiredScopes": { + "description": "Scopes the credential must carry.", "type": "array", "items": { - "description": "Element of an array-valued default. Shape varies by field type." + "type": "string" } } - ] - }, - "hasComputedDefault": { - "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", - "type": "boolean" + }, + "required": ["required", "provider"], + "additionalProperties": false } }, - "required": ["id", "type"], + "required": ["id", "name", "description", "hostedApiKey"], "additionalProperties": false, - "title": "Block field", - "description": "One configuration field on a block." + "title": "Tool summary", + "description": "List view of a built-in tool: identity, auth, and key hosting." }, - "V2CatalogCondition": { + "ListToolsResponse": { "type": "object", "properties": { - "field": { - "type": "string", - "description": "Sibling field id whose value decides this condition." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolSummary" + }, + "description": "Items in the current page." }, - "value": { + "nextCursor": { "anyOf": [ { "type": "string" }, { - "type": "number" - }, - { - "type": "boolean" - }, + "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 tools response", + "description": "Built-in tools available in the workspace.", + "examples": [ + { + "data": [ { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] } } ], - "description": "Value, or set of accepted values, the named field must hold." - }, - "not": { - "description": "Invert the match: every value EXCEPT `value`.", - "type": "boolean" - }, - "and": { - "description": "A second clause that must hold as well.", - "type": "object", - "properties": { - "field": { - "type": "string", - "description": "Sibling field id for the second clause." + "nextCursor": null + } + ] + }, + "GetToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ToolDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get tool response", + "description": "One built-in tool with its parameters and outputs.", + "examples": [ + { + "data": { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] }, - "value": { - "description": "Value the second clause matches. Absent means \"holds any value\".", - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } + "params": { + "channel": { + "type": "string", + "required": true, + "description": "Channel ID to post into." + }, + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + } + ] + }, + "V2ToolExecution": { + "type": "object", + "properties": { + "toolId": { + "type": "string", + "description": "Tool that ran. An unversioned name resolves to the newest version visible in the workspace, so this can differ from the id in the path." + }, + "status": { + "type": "string", + "enum": ["succeeded", "failed"], + "description": "Whether the tool reported success. A failed tool call is still a 200." + }, + "output": { + "description": "Whatever the tool produced, shaped by its declared outputs." + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Why the tool call did not succeed." } - ] + }, + "required": ["message"], + "additionalProperties": false }, - "not": { - "description": "Invert the second clause.", - "type": "boolean" + { + "type": "null" } - }, - "required": ["field"], - "additionalProperties": false + ], + "description": "Populated only when `status` is `failed`." } }, - "required": ["field", "value"], + "required": ["toolId", "status", "output", "error"], "additionalProperties": false, - "title": "Catalog condition", - "description": "When a configuration field applies, expressed against a sibling field." + "title": "Tool execution", + "description": "The result of running one built-in tool." }, - "V2OperationInput": { + "ExecuteToolResponse": { "type": "object", "properties": { - "type": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ToolExecution" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Run tool response", + "description": "What the tool produced, or why it did not succeed.", + "examples": [ + { + "data": { + "toolId": "slack_message", + "status": "succeeded", + "output": { + "ts": "1718191234.004500" + }, + "error": null + } + } + ] + }, + "ExecuteToolRequest": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", - "description": "Value type." - }, - "required": { - "description": "Whether the value must be supplied.", - "type": "boolean" - }, - "visibility": { - "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", - "type": "string" - }, - "description": { - "description": "What the value means.", - "type": "string" + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, credentials, and environment variables govern this call." }, - "default": { - "description": "Value used when this input is omitted." + "input": { + "default": {}, + "description": "Tool arguments keyed by published parameter IDs. For `user-only` parameters, a whole-value `{{VAR_NAME}}` reference resolves a workspace environment variable. Other values pass through unchanged.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One argument value. Its shape is declared by the tool parameter." + } }, - "items": { - "description": "JSON-Schema-shaped constraints declared by the tool parameter." + "credentialId": { + "description": "Credential to authenticate with. Required when the tool declares an OAuth requirement; the workspace credentials list names the candidates.", + "type": "string", + "minLength": 1, + "maxLength": 255 }, - "schema": { - "description": "JSON-Schema-shaped structure declared by the block input." + "timeoutSeconds": { + "description": "How long to wait for the tool before abandoning the call.", + "type": "integer", + "minimum": 1, + "maximum": 300 } }, - "required": ["type"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Operation input", - "description": "One value a block operation needs, from its tool or its block-level inputs." + "title": "Run tool request", + "description": "Workspace, arguments, and the credential to authenticate with.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "input": { + "channel": "C0123456789", + "text": "Deploy finished." + }, + "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" + } + ] }, - "V2ToolOutput": { + "V2ConnectorType": { "type": "object", "properties": { - "type": { + "connectorType": { "type": "string", - "description": "Value type of the output field." + "description": "Exact identifier to send when creating a connector of this type." + }, + "name": { + "type": "string", + "description": "Display name." }, "description": { - "description": "What the field holds.", - "type": "string" + "type": "string", + "description": "What the connector syncs." }, - "optional": { - "description": "Whether the field may be absent.", - "type": "boolean" + "version": { + "type": "string", + "description": "Connector version." }, - "nullable": { - "description": "Whether the field may be null.", - "type": "boolean" + "auth": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "oauth", + "description": "Authenticates with an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "provider"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "apiKey", + "description": "Authenticates with a stored API key." + }, + "label": { + "description": "Label shown above the key field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the key field.", + "type": "string" + }, + "optional": { + "type": "boolean", + "description": "Whether the key may be left blank, for a source reachable without authentication." + } + }, + "required": ["mode", "optional"], + "additionalProperties": false + } + ], + "description": "How the connector authenticates against its source." }, - "properties": { - "description": "Members of an object-typed output, keyed by field name.", - "type": "object", - "propertyNames": { - "type": "string" + "configFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorConfigField" }, - "additionalProperties": { - "description": "Nested output field, in this same shape." - } + "description": "Fields that make up the connector’s `sourceConfig`." }, - "items": { - "description": "Element shape of an array-typed output.", - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Element value type." - }, - "description": { - "description": "What an element holds.", - "type": "string" - }, + "supportsIncrementalSync": { + "type": "boolean", + "description": "Whether syncs after the first fetch only what changed." + }, + "tagDefinitions": { + "type": "array", + "items": { + "type": "object", "properties": { - "description": "Members of an object-typed element, keyed by field name.", - "type": "object", - "propertyNames": { - "type": "string" + "id": { + "type": "string", + "description": "Semantic tag identifier the connector populates." }, - "additionalProperties": { - "description": "Nested output field, in this same shape." + "displayName": { + "type": "string", + "description": "Human-readable tag name." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "description": "Value type, which decides the tag slot pool it draws from." } - } - }, - "required": ["type"], - "additionalProperties": false - }, - "fileConfig": { - "description": "File metadata for a file-typed output.", - "type": "object", - "properties": { - "mimeType": { - "description": "MIME type of the produced file.", - "type": "string" }, - "extension": { - "description": "File extension of the produced file.", - "type": "string" - } + "required": ["id", "displayName", "fieldType"], + "additionalProperties": false }, - "additionalProperties": false + "description": "Tags this connector writes onto the documents it syncs." } }, - "required": ["type"], + "required": [ + "connectorType", + "name", + "description", + "version", + "auth", + "configFields", + "supportsIncrementalSync", + "tagDefinitions" + ], "additionalProperties": false, - "title": "Tool output", - "description": "One declared output field of a built-in tool." + "title": "Connector type", + "description": "A knowledge-base connector type and the configuration it accepts." }, - "V2ToolDetail": { + "V2ConnectorConfigField": { "type": "object", "properties": { "id": { "type": "string", - "description": "Registered tool identifier, including its version suffix." + "description": "Field identifier." }, - "name": { + "title": { "type": "string", - "description": "Display name." + "description": "Human-readable label." }, - "description": { + "type": { "type": "string", - "description": "What the tool does." + "enum": ["short-input", "dropdown", "selector"], + "description": "Control the field renders as. A `selector` fetches its options from the connected account." }, - "version": { - "description": "Tool version.", + "placeholder": { + "description": "Placeholder shown in the editor.", "type": "string" }, - "hostedApiKey": { - "type": "string", - "enum": ["always", "conditional", "none"], - "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + "required": { + "description": "Whether a value must be supplied.", + "type": "boolean" }, - "oauth": { - "description": "OAuth requirement, when the tool has one.", - "type": "object", - "properties": { - "required": { - "type": "boolean", - "description": "Whether the tool cannot run without an OAuth credential." - }, - "provider": { - "type": "string", - "description": "OAuth service the credential must authenticate." + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "options": { + "description": "Static options, for a `dropdown` field.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } }, - "requiredScopes": { - "description": "Scopes the credential must carry.", + "required": ["id", "label"], + "additionalProperties": false + } + }, + "selectorKey": { + "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", + "type": "string" + }, + "mimeType": { + "description": "MIME type filter applied to the picker.", + "type": "string" + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { "type": "array", "items": { "type": "string" } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } - }, - "required": ["required", "provider"], - "additionalProperties": false + ] }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolParam" - }, - "description": "Parameters the tool accepts." + "mode": { + "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "type": "string", + "enum": ["basic", "advanced"] }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolOutput" - }, - "description": "Fields the tool produces." + "canonicalParamId": { + "description": "Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.", + "type": "string" + }, + "multi": { + "description": "When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.", + "type": "boolean" } }, - "required": ["id", "name", "description", "hostedApiKey", "params", "outputs"], + "required": ["id", "title", "type"], "additionalProperties": false, - "title": "Tool", - "description": "A built-in tool with its declared parameters and outputs." + "title": "Connector config field", + "description": "One field of a knowledge-base connector’s source configuration." }, - "V2ToolParam": { + "ListConnectorTypesResponse": { "type": "object", "properties": { - "type": { - "type": "string", - "description": "Parameter value type." - }, - "required": { - "description": "Whether the parameter must be supplied.", - "type": "boolean" - }, - "visibility": { - "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", - "type": "string" - }, - "description": { - "description": "What the parameter means.", - "type": "string" - }, - "default": { - "description": "Value used when the parameter is omitted." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorType" + }, + "description": "Items in the current page." }, - "items": { - "description": "JSON-Schema-shaped constraints for structured params." + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List connector types response", + "description": "Knowledge-base connector types and their configuration fields.", + "examples": [ + { + "data": [ + { + "connectorType": "google_drive", + "name": "Google Drive", + "description": "Sync documents from a Google Drive folder.", + "version": "1.0.0", + "auth": { + "mode": "oauth", + "provider": "google-drive", + "requiredScopes": ["https://www.googleapis.com/auth/drive.readonly"] + }, + "configFields": [ + { + "id": "folderSelector", + "title": "Folder", + "type": "selector", + "selectorKey": "google-drive-folder", + "mimeType": "application/vnd.google-apps.folder", + "mode": "basic", + "canonicalParamId": "folderId", + "required": true + }, + { + "id": "manualFolderId", + "title": "Folder ID", + "type": "short-input", + "placeholder": "Enter the folder ID", + "mode": "advanced", + "canonicalParamId": "folderId" + } + ], + "supportsIncrementalSync": true, + "tagDefinitions": [ + { + "id": "owner", + "displayName": "Owner", + "fieldType": "text" + } + ] + } + ], + "nextCursor": null } - }, - "required": ["type"], - "additionalProperties": false, - "title": "Tool parameter", - "description": "One declared parameter of a built-in tool." + ] }, - "V2BlockDetail": { + "V2PermissionGroup": { "type": "object", "properties": { "id": { "type": "string", - "description": "Block type identifier, used as a workflow block’s `type`." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "description": { - "type": "string", - "description": "One-line summary of what the block does." + "description": "Permission group identifier." }, - "longDescription": { - "description": "Extended explanation, when the block has one.", - "type": "string" - }, - "category": { + "organizationId": { "type": "string", - "description": "Toolbar category: `blocks`, `tools`, or `triggers`." - }, - "integrationType": { - "description": "Integration category, e.g. `communication`, `databases`.", - "type": "string" + "description": "Organization that owns the group." }, - "source": { + "name": { "type": "string", - "enum": ["builtin", "custom"], - "description": "Block source: `builtin` for built-in blocks, or `custom` for workflows this workspace deployed as blocks." - }, - "authMode": { - "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", - "type": "string" - }, - "triggerAllowed": { - "type": "boolean", - "description": "Whether the block declares itself usable as a trigger." - }, - "triggerCapable": { - "type": "boolean", - "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." - }, - "triggerIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Identifiers of the triggers this block supports." - }, - "toolIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Built-in tools this block can run. Read a tool by its id for the full definition." - }, - "operationIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Operations this block exposes. Their fields and tools are on the block read." - }, - "preview": { - "type": "boolean", - "description": "Whether the block is unreleased and revealed only to this caller." + "description": "Group name, unique within the organization." }, - "sunset": { - "description": "Post-release lifecycle state. Absent for a block in normal support.", - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["legacy", "deprecated"], - "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." - }, - "replacedBy": { - "description": "Block type to migrate to, when one exists.", + "description": { + "anyOf": [ + { "type": "string" + }, + { + "type": "null" } - }, - "required": ["status"], - "additionalProperties": false - }, - "docsLink": { - "description": "Sim documentation page for the integration.", - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Catalog tags, e.g. `messaging`, `version-control`." - }, - "bestPractices": { - "description": "Authored guidance on using the block correctly.", - "type": "string" - }, - "inputSchema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2BlockField" - }, - "description": "Configuration fields that apply regardless of the selected operation." - }, - "operationInputSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2BlockField" - } - }, - "description": "Configuration fields keyed by the operation that reveals them." + ], + "description": "Optional description of the group." }, - "inputDefinitions": { + "config": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`." - }, - "description": { - "description": "What the input means.", - "type": "string" - }, - "schema": { - "description": "JSON-Schema-shaped structure for object and array inputs." - } + "properties": { + "allowedIntegrations": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." }, - "required": ["type"], - "additionalProperties": false - }, - "description": "Block-level input definitions, keyed by parameter name." - }, - "operations": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "toolId": { - "description": "Built-in tool that performs this operation.", - "type": "string" - }, - "toolName": { - "description": "Display name of that tool.", + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "default": [], + "type": "array", + "items": { "type": "string" }, - "description": { - "description": "What the operation does.", + "description": "Models listed in this list are blocked." + }, + "deniedTools": { + "default": [], + "type": "array", + "items": { "type": "string" }, - "inputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2OperationInput" - }, - "description": "Values this operation needs, excluding the ones the block supplies from its own block-level inputs." - }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" + "description": "Integration tools listed in this list are blocked." + }, + "hideTraceSpans": { + "type": "boolean", + "description": "Withhold per-block trace spans from logs and from the API." + }, + "hideKnowledgeBaseTab": { + "type": "boolean", + "description": "Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base." + }, + "hideTablesTab": { + "type": "boolean", + "description": "Revoke the Tables module. Members cannot read or write any table." + }, + "hideCopilot": { + "type": "boolean", + "description": "Revoke Chat. Members cannot ask Sim to build or edit anything." + }, + "hideIntegrationsTab": { + "type": "boolean", + "description": "Revoke integration connections. Members cannot view, add, or remove an OAuth connection." + }, + "hideSecretsTab": { + "type": "boolean", + "description": "Revoke secrets. Members cannot read, add, or change a workspace environment variable." + }, + "hideApiKeysTab": { + "type": "boolean", + "description": "Revoke workspace API keys. Members cannot list, create, or revoke one." + }, + "hideInboxTab": { + "type": "boolean", + "description": "Revoke the Sim Mailer inbox. Members cannot read or send mail." + }, + "hideFilesTab": { + "type": "boolean", + "description": "Revoke the Files module. Members cannot list, upload, or download workspace files." + }, + "disableMcpTools": { + "type": "boolean", + "description": "Block agents from calling MCP tools." + }, + "disableCustomTools": { + "type": "boolean", + "description": "Block agents from calling user-defined custom tools." + }, + "disableSkills": { + "type": "boolean", + "description": "Block agents from loading skills." + }, + "disableInvitations": { + "type": "boolean", + "description": "Prevent inviting anyone to a workspace or to the organization." + }, + "disablePublicApi": { + "type": "boolean", + "description": "Revoke public API access. Calls to a deployed workflow are refused." + }, + "disablePublicFileSharing": { + "type": "boolean", + "description": "Revoke public file sharing. Members cannot create a share link." + }, + "allowedFileShareAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": ["public", "password", "email", "sso"] + } }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolOutput" + { + "type": "null" + } + ], + "description": "Public file-share authentication is limited to this list. Null permits every value; an empty list permits none." + }, + "hideDeployApi": { + "type": "boolean", + "description": "Prevent deploying a workflow as an API endpoint." + }, + "hideDeployMcp": { + "type": "boolean", + "description": "Prevent exposing a workflow as an MCP server." + }, + "hideDeployChatbot": { + "type": "boolean", + "description": "Prevent publishing a workflow as a chat." + }, + "allowedChatDeployAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": ["public", "password", "email", "sso"] + } }, - "description": "Fields the operation produces." - }, - "inputSchema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2BlockField" + { + "type": "null" + } + ], + "description": "Chat deployment authentication is limited to this list. Null permits every value; an empty list permits none." + }, + "disablePersonalApiKeys": { + "type": "boolean", + "description": "Prevent members from using a personal API key against this workspace." + }, + "disableLogExport": { + "type": "boolean", + "description": "Prevent downloading execution logs as a CSV." + }, + "hideCostInfo": { + "type": "boolean", + "description": "Withhold execution cost. Logs and member exports omit cost and token spend; organization-level data drains, configurable by org admins only, are not projected." + }, + "disableKnowledgeBaseCreation": { + "type": "boolean", + "description": "Prevent creating knowledge bases, leaving existing ones queryable." + }, + "disableKnowledgeBaseFileUpload": { + "type": "boolean", + "description": "Prevent uploading local documents, leaving sanctioned connectors as the only source." + }, + "allowedKnowledgeConnectors": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } }, - "description": "Configuration fields that appear when this operation is selected." - } + { + "type": "null" + } + ], + "description": "Knowledge base connectors are limited to this list. Null permits every value; an empty list permits none." }, - "required": ["inputs", "outputs", "inputSchema"], - "additionalProperties": false + "disableTableCreation": { + "type": "boolean", + "description": "Prevent creating tables, leaving existing ones usable." + }, + "disableTableExport": { + "type": "boolean", + "description": "Prevent downloading a whole table as CSV or JSON." + }, + "disableBulkFileDownload": { + "type": "boolean", + "description": "Prevent downloading folders as an archive." + }, + "disablePersonalCredentials": { + "type": "boolean", + "description": "Prevent connecting personal credentials, leaving only workspace-shared ones." + }, + "disableWorkspaceCreation": { + "type": "boolean", + "description": "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none." + }, + "hideOrgMemberDirectory": { + "type": "boolean", + "description": "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace." + }, + "disableCliAccess": { + "type": "boolean", + "description": "Prevent approving a CLI login or using Sim CLI OAuth tokens for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group." + }, + "disableWebhookTriggers": { + "type": "boolean", + "description": "Prevent making a workflow reachable from an inbound webhook." + }, + "disableToolAutoApproval": { + "type": "boolean", + "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + }, + "hideSandboxesTab": { + "type": "boolean", + "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + }, + "disableOAuthAppAccess": { + "type": "boolean", + "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + }, + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." + } }, - "description": "Operations the block exposes, keyed by operation id." + "required": [ + "allowedIntegrations", + "allowedModelProviders", + "deniedModels", + "deniedTools", + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "allowedFileShareAuthTypes", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "allowedChatDeployAuthTypes", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "allowedKnowledgeConnectors", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "additionalProperties": false, + "description": "Resolved restrictions. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none." }, - "tools": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ToolDetail" - }, - "description": "Every built-in tool the block can run, with parameters and outputs." + "isDefault": { + "type": "boolean", + "description": "Whether this is the organization default, which applies across all its workspaces." }, - "triggers": { + "membershipMode": { + "type": "string", + "description": "An empty inherit group governs everyone in its workspaces; an empty explicit group governs nobody." + }, + "workspaceIds": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Trigger identifier." - }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Value type of the output." - }, - "description": { - "description": "What the output holds.", - "type": "string" - } - }, - "required": ["type"], - "additionalProperties": false - }, - "description": "Top-level fields the trigger event delivers." - }, - "configFields": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Editor control the field renders as." - }, - "required": { - "type": "boolean", - "description": "Whether a value must be supplied." - }, - "title": { - "description": "Human-readable label.", - "type": "string" - }, - "description": { - "description": "Authored explanation of the field.", - "type": "string" - }, - "placeholder": { - "description": "Placeholder shown in the editor.", - "type": "string" - }, - "default": { - "description": "Value used when the field is left unset." - }, - "options": { - "description": "Selectable options.", - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Value stored when this option is selected." - }, - "label": { - "type": "string", - "description": "Human-readable option label." - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "condition": { - "description": "Condition under which the field applies.", - "$ref": "#/components/schemas/V2CatalogCondition" - } - }, - "required": ["type", "required"], - "additionalProperties": false - }, - "description": "Fields that configure the trigger, keyed by field id." - } - }, - "required": ["id", "outputs", "configFields"], - "additionalProperties": false - }, - "description": "Triggers the block can run on." - }, - "outputs": { - "type": "object", - "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Value type of the output." - }, - "description": { - "description": "What the output holds.", - "type": "string" - } - }, - "required": ["type"], - "additionalProperties": false - }, - "description": "Fields the block produces." + "description": "Workspaces governed by a non-default group. Empty for the default group." + }, + "createdBy": { + "type": "string", + "description": "User who created the group." + }, + "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": "When the group 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": "When the group was last updated." } }, "required": [ "id", + "organizationId", "name", "description", - "category", - "source", - "triggerAllowed", - "triggerCapable", - "triggerIds", - "toolIds", - "operationIds", - "preview", - "tags", - "inputSchema", - "operationInputSchema", - "inputDefinitions", - "operations", - "tools", - "triggers", - "outputs" + "config", + "isDefault", + "membershipMode", + "workspaceIds", + "createdBy", + "createdAt", + "updatedAt" ], "additionalProperties": false, - "title": "Block", - "description": "A block with its configuration fields, operations, tools, and triggers." + "title": "Permission group", + "description": "An organization permission group and its resolved restrictions." }, - "GetBlockResponse": { + "ListPermissionGroupsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2PermissionGroup" + }, + "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 Permission Groups response", + "description": "List Permission Groups result.", + "examples": [ + { + "data": [ + { + "id": "group-123", + "organizationId": "org-123", + "name": "Restricted", + "description": null, + "config": { + "allowedIntegrations": null, + "allowedModelProviders": null, + "deniedModels": [], + "deniedTools": [], + "hideTraceSpans": false, + "hideKnowledgeBaseTab": false, + "hideTablesTab": false, + "hideCopilot": false, + "hideIntegrationsTab": false, + "hideSecretsTab": false, + "hideApiKeysTab": false, + "hideInboxTab": false, + "hideFilesTab": false, + "disableMcpTools": false, + "disableCustomTools": false, + "disableSkills": false, + "disableInvitations": false, + "disablePublicApi": false, + "disablePublicFileSharing": false, + "allowedFileShareAuthTypes": null, + "hideDeployApi": false, + "hideDeployMcp": false, + "hideDeployChatbot": false, + "allowedChatDeployAuthTypes": null, + "disablePersonalApiKeys": false, + "disableLogExport": false, + "hideCostInfo": false, + "disableKnowledgeBaseCreation": false, + "disableKnowledgeBaseFileUpload": false, + "allowedKnowledgeConnectors": null, + "disableTableCreation": false, + "disableTableExport": false, + "disableBulkFileDownload": false, + "disablePersonalCredentials": false, + "disableWorkspaceCreation": false, + "hideOrgMemberDirectory": false, + "disableCliAccess": false, + "disableWebhookTriggers": false, + "disableToolAutoApproval": false, + "hideSandboxesTab": false, + "disableOAuthAppAccess": false, + "disableKnowledgeBaseExport": false + }, + "isDefault": false, + "membershipMode": "inherit", + "workspaceIds": ["workspace-123"], + "createdBy": "admin-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreatePermissionGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2BlockDetail" + "$ref": "#/components/schemas/V2PermissionGroup" } }, "required": ["data"], "additionalProperties": false, - "title": "Get block response", - "description": "One block with its fields, operations, tools, and triggers.", + "title": "Create Permission Group response", + "description": "Create Permission Group result.", "examples": [ { "data": { - "id": "slack", - "name": "Slack", - "description": "Send messages and read channels in Slack.", - "category": "tools", - "integrationType": "communication", - "source": "builtin", - "authMode": "oauth", - "triggerAllowed": true, - "triggerCapable": true, - "triggerIds": ["slack_webhook"], - "toolIds": ["slack_message", "slack_canvas_read"], - "operationIds": ["send", "read"], - "preview": false, - "docsLink": "https://docs.sim.ai/tools/slack", - "tags": ["messaging"], - "inputSchema": [ - { - "id": "operation", - "type": "dropdown", - "title": "Operation", - "required": true, - "options": [ - { - "id": "send", - "label": "Send message" - }, - { - "id": "read", - "label": "Read messages" + "id": "group-123", + "organizationId": "org-123", + "name": "Restricted", + "description": null, + "config": { + "allowedIntegrations": null, + "allowedModelProviders": null, + "deniedModels": [], + "deniedTools": [], + "hideTraceSpans": false, + "hideKnowledgeBaseTab": false, + "hideTablesTab": false, + "hideCopilot": false, + "hideIntegrationsTab": false, + "hideSecretsTab": false, + "hideApiKeysTab": false, + "hideInboxTab": false, + "hideFilesTab": false, + "disableMcpTools": false, + "disableCustomTools": false, + "disableSkills": false, + "disableInvitations": false, + "disablePublicApi": false, + "disablePublicFileSharing": false, + "allowedFileShareAuthTypes": null, + "hideDeployApi": false, + "hideDeployMcp": false, + "hideDeployChatbot": false, + "allowedChatDeployAuthTypes": null, + "disablePersonalApiKeys": false, + "disableLogExport": false, + "hideCostInfo": false, + "disableKnowledgeBaseCreation": false, + "disableKnowledgeBaseFileUpload": false, + "allowedKnowledgeConnectors": null, + "disableTableCreation": false, + "disableTableExport": false, + "disableBulkFileDownload": false, + "disablePersonalCredentials": false, + "disableWorkspaceCreation": false, + "hideOrgMemberDirectory": false, + "disableCliAccess": false, + "disableWebhookTriggers": false, + "disableToolAutoApproval": false, + "hideSandboxesTab": false, + "disableOAuthAppAccess": false, + "disableKnowledgeBaseExport": false + }, + "isDefault": false, + "membershipMode": "inherit", + "workspaceIds": ["workspace-123"], + "createdBy": "admin-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "CreatePermissionGroupRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." + }, + "description": { + "description": "Optional group description.", + "type": "string", + "maxLength": 500 + }, + "config": { + "type": "object", + "properties": { + "allowedIntegrations": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" } - ] - } - ], - "operationInputSchema": { - "send": [ + }, { - "id": "text", - "type": "long-input", - "title": "Message", - "required": true + "type": "null" } - ] - }, - "inputDefinitions": { - "channel": { - "type": "string", - "description": "Channel to post into." - } + ], + "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." }, - "operations": { - "send": { - "toolId": "slack_message", - "toolName": "Slack Send Message", - "description": "Send a message to a Slack channel.", - "inputs": { - "text": { - "type": "string", - "required": true, - "description": "Message body." + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" } }, - "outputs": { - "ts": { + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Models listed in this list are blocked." + }, + "deniedTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Integration tools listed in this list are blocked." + }, + "hideTraceSpans": { + "type": "boolean", + "description": "Withhold per-block trace spans from logs and from the API." + }, + "hideKnowledgeBaseTab": { + "type": "boolean", + "description": "Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base." + }, + "hideTablesTab": { + "type": "boolean", + "description": "Revoke the Tables module. Members cannot read or write any table." + }, + "hideCopilot": { + "type": "boolean", + "description": "Revoke Chat. Members cannot ask Sim to build or edit anything." + }, + "hideIntegrationsTab": { + "type": "boolean", + "description": "Revoke integration connections. Members cannot view, add, or remove an OAuth connection." + }, + "hideSecretsTab": { + "type": "boolean", + "description": "Revoke secrets. Members cannot read, add, or change a workspace environment variable." + }, + "hideApiKeysTab": { + "type": "boolean", + "description": "Revoke workspace API keys. Members cannot list, create, or revoke one." + }, + "hideInboxTab": { + "type": "boolean", + "description": "Revoke the Sim Mailer inbox. Members cannot read or send mail." + }, + "hideFilesTab": { + "type": "boolean", + "description": "Revoke the Files module. Members cannot list, upload, or download workspace files." + }, + "disableMcpTools": { + "type": "boolean", + "description": "Block agents from calling MCP tools." + }, + "disableCustomTools": { + "type": "boolean", + "description": "Block agents from calling user-defined custom tools." + }, + "disableSkills": { + "type": "boolean", + "description": "Block agents from loading skills." + }, + "disableInvitations": { + "type": "boolean", + "description": "Prevent inviting anyone to a workspace or to the organization." + }, + "disablePublicApi": { + "type": "boolean", + "description": "Revoke public API access. Calls to a deployed workflow are refused." + }, + "disablePublicFileSharing": { + "type": "boolean", + "description": "Revoke public file sharing. Members cannot create a share link." + }, + "allowedFileShareAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { "type": "string", - "description": "Message timestamp." + "enum": ["public", "password", "email", "sso"] } }, - "inputSchema": [ - { - "id": "text", - "type": "long-input", - "title": "Message", - "required": true - } - ] - } + { + "type": "null" + } + ], + "description": "Public file-share authentication is limited to this list. Null permits every value; an empty list permits none." }, - "tools": [ - { - "id": "slack_message", - "name": "Slack Send Message", - "description": "Send a message to a Slack channel.", - "version": "1.0.0", - "hostedApiKey": "none", - "oauth": { - "required": true, - "provider": "slack", - "requiredScopes": ["chat:write"] - }, - "params": { - "text": { + "hideDeployApi": { + "type": "boolean", + "description": "Prevent deploying a workflow as an API endpoint." + }, + "hideDeployMcp": { + "type": "boolean", + "description": "Prevent exposing a workflow as an MCP server." + }, + "hideDeployChatbot": { + "type": "boolean", + "description": "Prevent publishing a workflow as a chat." + }, + "allowedChatDeployAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { "type": "string", - "required": true, - "description": "Message body." + "enum": ["public", "password", "email", "sso"] } }, - "outputs": { - "ts": { - "type": "string", - "description": "Message timestamp." - } + { + "type": "null" } - } - ], - "triggers": [ - { - "id": "slack_webhook", - "outputs": { - "text": { - "type": "string", - "description": "Message text." + ], + "description": "Chat deployment authentication is limited to this list. Null permits every value; an empty list permits none." + }, + "disablePersonalApiKeys": { + "type": "boolean", + "description": "Prevent members from using a personal API key against this workspace." + }, + "disableLogExport": { + "type": "boolean", + "description": "Prevent downloading execution logs as a CSV." + }, + "hideCostInfo": { + "type": "boolean", + "description": "Withhold execution cost. Logs and member exports omit cost and token spend; organization-level data drains, configurable by org admins only, are not projected." + }, + "disableKnowledgeBaseCreation": { + "type": "boolean", + "description": "Prevent creating knowledge bases, leaving existing ones queryable." + }, + "disableKnowledgeBaseFileUpload": { + "type": "boolean", + "description": "Prevent uploading local documents, leaving sanctioned connectors as the only source." + }, + "allowedKnowledgeConnectors": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" } }, - "configFields": { - "channels": { - "type": "short-input", - "required": false, - "title": "Channels" - } + { + "type": "null" } - } - ], - "outputs": { - "ts": { - "type": "string", - "description": "Message timestamp." - } - } - } - } - ] - }, - "V2ToolSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Registered tool identifier, including its version suffix." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "description": { - "type": "string", - "description": "What the tool does." - }, - "version": { - "description": "Tool version.", - "type": "string" - }, - "hostedApiKey": { - "type": "string", - "enum": ["always", "conditional", "none"], - "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." - }, - "oauth": { - "description": "OAuth requirement, when the tool has one.", - "type": "object", - "properties": { - "required": { + ], + "description": "Knowledge base connectors are limited to this list. Null permits every value; an empty list permits none." + }, + "disableTableCreation": { + "type": "boolean", + "description": "Prevent creating tables, leaving existing ones usable." + }, + "disableTableExport": { "type": "boolean", - "description": "Whether the tool cannot run without an OAuth credential." + "description": "Prevent downloading a whole table as CSV or JSON." }, - "provider": { - "type": "string", - "description": "OAuth service the credential must authenticate." + "disableBulkFileDownload": { + "type": "boolean", + "description": "Prevent downloading folders as an archive." }, - "requiredScopes": { - "description": "Scopes the credential must carry.", - "type": "array", - "items": { - "type": "string" - } + "disablePersonalCredentials": { + "type": "boolean", + "description": "Prevent connecting personal credentials, leaving only workspace-shared ones." + }, + "disableWorkspaceCreation": { + "type": "boolean", + "description": "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none." + }, + "hideOrgMemberDirectory": { + "type": "boolean", + "description": "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace." + }, + "disableCliAccess": { + "type": "boolean", + "description": "Prevent approving a CLI login or using Sim CLI OAuth tokens for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group." + }, + "disableWebhookTriggers": { + "type": "boolean", + "description": "Prevent making a workflow reachable from an inbound webhook." + }, + "disableToolAutoApproval": { + "type": "boolean", + "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + }, + "hideSandboxesTab": { + "type": "boolean", + "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + }, + "disableOAuthAppAccess": { + "type": "boolean", + "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + }, + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." } }, - "required": ["required", "provider"], - "additionalProperties": false + "additionalProperties": false, + "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + }, + "isDefault": { + "description": "Whether the group is the organization default. Only one group can be the default.", + "type": "boolean" + }, + "workspaceIds": { + "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, - "required": ["id", "name", "description", "hostedApiKey"], + "required": ["name"], "additionalProperties": false, - "title": "Tool summary", - "description": "List view of a built-in tool: identity, auth, and key hosting." + "title": "Create Permission Group request", + "description": "Create Permission Group inputs.", + "examples": [ + { + "name": "Restricted", + "workspaceIds": ["workspace-123"] + } + ] }, - "ListToolsResponse": { + "GetPermissionGroupResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ToolSummary" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroup" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List tools response", - "description": "Built-in tools available in the workspace.", + "title": "Get Permission Group response", + "description": "Get Permission Group result.", "examples": [ { - "data": [ - { - "id": "slack_message", - "name": "Slack Send Message", - "description": "Send a message to a Slack channel.", - "version": "1.0.0", - "hostedApiKey": "none", - "oauth": { - "required": true, - "provider": "slack", - "requiredScopes": ["chat:write"] - } - } - ], - "nextCursor": null + "data": { + "id": "group-123", + "organizationId": "org-123", + "name": "Restricted", + "description": null, + "config": { + "allowedIntegrations": null, + "allowedModelProviders": null, + "deniedModels": [], + "deniedTools": [], + "hideTraceSpans": false, + "hideKnowledgeBaseTab": false, + "hideTablesTab": false, + "hideCopilot": false, + "hideIntegrationsTab": false, + "hideSecretsTab": false, + "hideApiKeysTab": false, + "hideInboxTab": false, + "hideFilesTab": false, + "disableMcpTools": false, + "disableCustomTools": false, + "disableSkills": false, + "disableInvitations": false, + "disablePublicApi": false, + "disablePublicFileSharing": false, + "allowedFileShareAuthTypes": null, + "hideDeployApi": false, + "hideDeployMcp": false, + "hideDeployChatbot": false, + "allowedChatDeployAuthTypes": null, + "disablePersonalApiKeys": false, + "disableLogExport": false, + "hideCostInfo": false, + "disableKnowledgeBaseCreation": false, + "disableKnowledgeBaseFileUpload": false, + "allowedKnowledgeConnectors": null, + "disableTableCreation": false, + "disableTableExport": false, + "disableBulkFileDownload": false, + "disablePersonalCredentials": false, + "disableWorkspaceCreation": false, + "hideOrgMemberDirectory": false, + "disableCliAccess": false, + "disableWebhookTriggers": false, + "disableToolAutoApproval": false, + "hideSandboxesTab": false, + "disableOAuthAppAccess": false, + "disableKnowledgeBaseExport": false + }, + "isDefault": false, + "membershipMode": "inherit", + "workspaceIds": ["workspace-123"], + "createdBy": "admin-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + } } ] }, - "GetToolResponse": { + "UpdatePermissionGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ToolDetail" + "$ref": "#/components/schemas/V2PermissionGroup" } }, "required": ["data"], "additionalProperties": false, - "title": "Get tool response", - "description": "One built-in tool with its parameters and outputs.", + "title": "Update Permission Group response", + "description": "Update Permission Group result.", "examples": [ { "data": { - "id": "slack_message", - "name": "Slack Send Message", - "description": "Send a message to a Slack channel.", - "version": "1.0.0", - "hostedApiKey": "none", - "oauth": { - "required": true, - "provider": "slack", - "requiredScopes": ["chat:write"] + "id": "group-123", + "organizationId": "org-123", + "name": "Restricted", + "description": null, + "config": { + "allowedIntegrations": null, + "allowedModelProviders": null, + "deniedModels": [], + "deniedTools": [], + "hideTraceSpans": false, + "hideKnowledgeBaseTab": false, + "hideTablesTab": false, + "hideCopilot": false, + "hideIntegrationsTab": false, + "hideSecretsTab": false, + "hideApiKeysTab": false, + "hideInboxTab": false, + "hideFilesTab": false, + "disableMcpTools": false, + "disableCustomTools": false, + "disableSkills": false, + "disableInvitations": false, + "disablePublicApi": false, + "disablePublicFileSharing": false, + "allowedFileShareAuthTypes": null, + "hideDeployApi": false, + "hideDeployMcp": false, + "hideDeployChatbot": false, + "allowedChatDeployAuthTypes": null, + "disablePersonalApiKeys": false, + "disableLogExport": false, + "hideCostInfo": false, + "disableKnowledgeBaseCreation": false, + "disableKnowledgeBaseFileUpload": false, + "allowedKnowledgeConnectors": null, + "disableTableCreation": false, + "disableTableExport": false, + "disableBulkFileDownload": false, + "disablePersonalCredentials": false, + "disableWorkspaceCreation": false, + "hideOrgMemberDirectory": false, + "disableCliAccess": false, + "disableWebhookTriggers": false, + "disableToolAutoApproval": false, + "hideSandboxesTab": false, + "disableOAuthAppAccess": false, + "disableKnowledgeBaseExport": false + }, + "isDefault": false, + "membershipMode": "inherit", + "workspaceIds": ["workspace-123"], + "createdBy": "admin-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "UpdatePermissionGroupRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." + }, + "description": { + "description": "Group description. Null clears it; omission leaves it unchanged.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 }, - "params": { - "channel": { - "type": "string", - "required": true, - "description": "Channel ID to post into." + { + "type": "null" + } + ] + }, + "config": { + "type": "object", + "properties": { + "allowedIntegrations": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." + }, + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "type": "array", + "items": { + "type": "string" }, - "text": { - "type": "string", - "required": true, - "description": "Message body." - } + "description": "Models listed in this list are blocked." + }, + "deniedTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Integration tools listed in this list are blocked." + }, + "hideTraceSpans": { + "type": "boolean", + "description": "Withhold per-block trace spans from logs and from the API." + }, + "hideKnowledgeBaseTab": { + "type": "boolean", + "description": "Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base." + }, + "hideTablesTab": { + "type": "boolean", + "description": "Revoke the Tables module. Members cannot read or write any table." + }, + "hideCopilot": { + "type": "boolean", + "description": "Revoke Chat. Members cannot ask Sim to build or edit anything." + }, + "hideIntegrationsTab": { + "type": "boolean", + "description": "Revoke integration connections. Members cannot view, add, or remove an OAuth connection." + }, + "hideSecretsTab": { + "type": "boolean", + "description": "Revoke secrets. Members cannot read, add, or change a workspace environment variable." + }, + "hideApiKeysTab": { + "type": "boolean", + "description": "Revoke workspace API keys. Members cannot list, create, or revoke one." + }, + "hideInboxTab": { + "type": "boolean", + "description": "Revoke the Sim Mailer inbox. Members cannot read or send mail." + }, + "hideFilesTab": { + "type": "boolean", + "description": "Revoke the Files module. Members cannot list, upload, or download workspace files." + }, + "disableMcpTools": { + "type": "boolean", + "description": "Block agents from calling MCP tools." + }, + "disableCustomTools": { + "type": "boolean", + "description": "Block agents from calling user-defined custom tools." + }, + "disableSkills": { + "type": "boolean", + "description": "Block agents from loading skills." + }, + "disableInvitations": { + "type": "boolean", + "description": "Prevent inviting anyone to a workspace or to the organization." + }, + "disablePublicApi": { + "type": "boolean", + "description": "Revoke public API access. Calls to a deployed workflow are refused." + }, + "disablePublicFileSharing": { + "type": "boolean", + "description": "Revoke public file sharing. Members cannot create a share link." + }, + "allowedFileShareAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": ["public", "password", "email", "sso"] + } + }, + { + "type": "null" + } + ], + "description": "Public file-share authentication is limited to this list. Null permits every value; an empty list permits none." + }, + "hideDeployApi": { + "type": "boolean", + "description": "Prevent deploying a workflow as an API endpoint." + }, + "hideDeployMcp": { + "type": "boolean", + "description": "Prevent exposing a workflow as an MCP server." + }, + "hideDeployChatbot": { + "type": "boolean", + "description": "Prevent publishing a workflow as a chat." + }, + "allowedChatDeployAuthTypes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": ["public", "password", "email", "sso"] + } + }, + { + "type": "null" + } + ], + "description": "Chat deployment authentication is limited to this list. Null permits every value; an empty list permits none." + }, + "disablePersonalApiKeys": { + "type": "boolean", + "description": "Prevent members from using a personal API key against this workspace." + }, + "disableLogExport": { + "type": "boolean", + "description": "Prevent downloading execution logs as a CSV." + }, + "hideCostInfo": { + "type": "boolean", + "description": "Withhold execution cost. Logs and member exports omit cost and token spend; organization-level data drains, configurable by org admins only, are not projected." + }, + "disableKnowledgeBaseCreation": { + "type": "boolean", + "description": "Prevent creating knowledge bases, leaving existing ones queryable." + }, + "disableKnowledgeBaseFileUpload": { + "type": "boolean", + "description": "Prevent uploading local documents, leaving sanctioned connectors as the only source." + }, + "allowedKnowledgeConnectors": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Knowledge base connectors are limited to this list. Null permits every value; an empty list permits none." + }, + "disableTableCreation": { + "type": "boolean", + "description": "Prevent creating tables, leaving existing ones usable." + }, + "disableTableExport": { + "type": "boolean", + "description": "Prevent downloading a whole table as CSV or JSON." + }, + "disableBulkFileDownload": { + "type": "boolean", + "description": "Prevent downloading folders as an archive." + }, + "disablePersonalCredentials": { + "type": "boolean", + "description": "Prevent connecting personal credentials, leaving only workspace-shared ones." + }, + "disableWorkspaceCreation": { + "type": "boolean", + "description": "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none." + }, + "hideOrgMemberDirectory": { + "type": "boolean", + "description": "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace." + }, + "disableCliAccess": { + "type": "boolean", + "description": "Prevent approving a CLI login or using Sim CLI OAuth tokens for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group." }, - "outputs": { - "ts": { - "type": "string", - "description": "Message timestamp." - } + "disableWebhookTriggers": { + "type": "boolean", + "description": "Prevent making a workflow reachable from an inbound webhook." + }, + "disableToolAutoApproval": { + "type": "boolean", + "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + }, + "hideSandboxesTab": { + "type": "boolean", + "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + }, + "disableOAuthAppAccess": { + "type": "boolean", + "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + }, + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." } + }, + "additionalProperties": false, + "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + }, + "isDefault": { + "description": "Whether the group is the organization default. Only one group can be the default.", + "type": "boolean" + }, + "workspaceIds": { + "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 } } + }, + "additionalProperties": false, + "title": "Update Permission Group request", + "description": "Update Permission Group inputs.", + "examples": [ + { + "description": "Restricted workspace access" + } ] }, - "V2ToolExecution": { + "V2PermissionGroupDeletion": { "type": "object", "properties": { - "toolId": { - "type": "string", - "description": "Tool that ran. An unversioned name resolves to the newest version visible in the workspace, so this can differ from the id in the path." - }, - "status": { + "id": { "type": "string", - "enum": ["succeeded", "failed"], - "description": "Whether the tool reported success. A failed tool call is still a 200." - }, - "output": { - "description": "Whatever the tool produced, shaped by its declared outputs." + "description": "Deleted permission group identifier." }, - "error": { - "anyOf": [ - { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Why the tool call did not succeed." - } - }, - "required": ["message"], - "additionalProperties": false - }, - { - "type": "null" - } - ], - "description": "Populated only when `status` is `failed`." + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the group was permanently deleted." } }, - "required": ["toolId", "status", "output", "error"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Tool execution", - "description": "The result of running one built-in tool." + "title": "Permission group deletion", + "description": "Acknowledges permanent group deletion." }, - "ExecuteToolResponse": { + "DeletePermissionGroupResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ToolExecution" + "$ref": "#/components/schemas/V2PermissionGroupDeletion" } }, "required": ["data"], "additionalProperties": false, - "title": "Run tool response", - "description": "What the tool produced, or why it did not succeed.", + "title": "Delete Permission Group response", + "description": "Delete Permission Group result.", "examples": [ { "data": { - "toolId": "slack_message", - "status": "succeeded", - "output": { - "ts": "1718191234.004500" - }, - "error": null - } - } - ] - }, - "ExecuteToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose integration allowlist, credentials, and environment variables govern this call." - }, - "input": { - "default": {}, - "description": "Tool arguments keyed by published parameter IDs. For `user-only` parameters, a whole-value `{{VAR_NAME}}` reference resolves a workspace environment variable. Other values pass through unchanged.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One argument value. Its shape is declared by the tool parameter." + "id": "group-123", + "deleted": true } - }, - "credentialId": { - "description": "Credential to authenticate with. Required when the tool declares an OAuth requirement; the workspace credentials list names the candidates.", - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "timeoutSeconds": { - "description": "How long to wait for the tool before abandoning the call.", - "type": "integer", - "minimum": 1, - "maximum": 300 - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Run tool request", - "description": "Workspace, arguments, and the credential to authenticate with.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "input": { - "channel": "C0123456789", - "text": "Deploy finished." - }, - "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" } ] }, - "V2ConnectorType": { + "V2PermissionGroupMember": { "type": "object", "properties": { - "connectorType": { + "id": { "type": "string", - "description": "Exact identifier to send when creating a connector of this type." + "description": "Membership assignment identifier." }, - "name": { + "userId": { "type": "string", - "description": "Display name." + "description": "Organization member assigned to the group." }, - "description": { + "assignedAt": { "type": "string", - "description": "What the connector syncs." + "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": "When the member was assigned." }, - "version": { - "type": "string", - "description": "Connector version." + "userName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member display name." }, - "auth": { - "oneOf": [ + "userEmail": { + "anyOf": [ { - "type": "object", - "properties": { - "mode": { - "type": "string", - "const": "oauth", - "description": "Authenticates with an OAuth credential." - }, - "provider": { - "type": "string", - "description": "OAuth service the credential must authenticate." - }, - "requiredScopes": { - "description": "Scopes the credential must carry.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["mode", "provider"], - "additionalProperties": false + "type": "string" }, { - "type": "object", - "properties": { - "mode": { - "type": "string", - "const": "apiKey", - "description": "Authenticates with a stored API key." - }, - "label": { - "description": "Label shown above the key field.", - "type": "string" - }, - "placeholder": { - "description": "Placeholder shown in the key field.", - "type": "string" - }, - "optional": { - "type": "boolean", - "description": "Whether the key may be left blank, for a source reachable without authentication." - } - }, - "required": ["mode", "optional"], - "additionalProperties": false + "type": "null" } ], - "description": "How the connector authenticates against its source." + "description": "Member email address." }, - "configFields": { + "userImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member avatar URL." + } + }, + "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "additionalProperties": false, + "title": "Permission group member", + "description": "An explicit permission-group membership assignment." + }, + "ListPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ConnectorConfigField" + "$ref": "#/components/schemas/V2PermissionGroupMember" }, - "description": "Fields that make up the connector’s `sourceConfig`." - }, - "supportsIncrementalSync": { - "type": "boolean", - "description": "Whether syncs after the first fetch only what changed." + "description": "Items in the current page." }, - "tagDefinitions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Semantic tag identifier the connector populates." - }, - "displayName": { - "type": "string", - "description": "Human-readable tag name." - }, - "fieldType": { - "type": "string", - "enum": ["text", "number", "date", "boolean"], - "description": "Value type, which decides the tag slot pool it draws from." - } - }, - "required": ["id", "displayName", "fieldType"], - "additionalProperties": false - }, - "description": "Tags this connector writes onto the documents it syncs." - } - }, - "required": [ - "connectorType", - "name", - "description", - "version", - "auth", - "configFields", - "supportsIncrementalSync", - "tagDefinitions" - ], + "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": "Connector type", - "description": "A knowledge-base connector type and the configuration it accepts." + "title": "List Permission Group Members response", + "description": "List Permission Group Members result.", + "examples": [ + { + "data": [ + { + "id": "assignment-123", + "userId": "user-123", + "assignedAt": "2026-06-01T09:00:00.000Z", + "userName": "Example Member", + "userEmail": "member@example.com", + "userImage": null + } + ], + "nextCursor": null + } + ] }, - "V2ConnectorConfigField": { + "V2PermissionGroupAssignment": { "type": "object", "properties": { "id": { "type": "string", - "description": "Field identifier." + "description": "Membership assignment identifier." }, - "title": { + "permissionGroupId": { "type": "string", - "description": "Human-readable label." + "description": "Group receiving the member." }, - "type": { + "organizationId": { "type": "string", - "enum": ["short-input", "dropdown", "selector"], - "description": "Control the field renders as. A `selector` fetches its options from the connected account." + "description": "Organization that owns the group." }, - "placeholder": { - "description": "Placeholder shown in the editor.", - "type": "string" + "userId": { + "type": "string", + "description": "User assigned to the group." }, - "required": { - "description": "Whether a value must be supplied.", - "type": "boolean" + "assignedBy": { + "type": "string", + "description": "User who made the assignment." }, - "description": { - "description": "Authored explanation of the field.", - "type": "string" + "assignedAt": { + "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": "When the assignment was created." + } + }, + "required": [ + "id", + "permissionGroupId", + "organizationId", + "userId", + "assignedBy", + "assignedAt" + ], + "additionalProperties": false, + "title": "Permission group assignment", + "description": "The newly created membership assignment." + }, + "AddPermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupAssignment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Add Permission Group Member response", + "description": "Add Permission Group Member result.", + "examples": [ + { + "data": { + "id": "assignment-123", + "permissionGroupId": "group-123", + "organizationId": "org-123", + "userId": "user-123", + "assignedBy": "admin-123", + "assignedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "AddPermissionGroupMemberRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "Existing organization member to add." + } + }, + "required": ["userId"], + "additionalProperties": false, + "title": "Add Permission Group Member request", + "description": "Add Permission Group Member inputs.", + "examples": [ + { + "userId": "user-123" + } + ] + }, + "V2PermissionGroupMemberDeletion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Removed membership assignment identifier." }, - "options": { - "description": "Static options, for a `dropdown` field.", + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the assignment was removed." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Permission group member deletion", + "description": "Acknowledges membership removal." + }, + "RemovePermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Remove Permission Group Member response", + "description": "Remove Permission Group Member result.", + "examples": [ + { + "data": { + "id": "assignment-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupBulkAdd": { + "type": "object", + "properties": { + "added": { + "type": "number", + "description": "Number of members added." + }, + "skipped": { + "type": "number", + "description": "Number of selected organization members already in the group." + } + }, + "required": ["added", "skipped"], + "additionalProperties": false, + "title": "Permission group bulk addition", + "description": "Counts of added and already assigned organization members." + }, + "BulkAddPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Bulk Add Permission Group Members response", + "description": "Bulk Add Permission Group Members result.", + "examples": [ + { + "data": { + "added": 1, + "skipped": 0 + } + } + ] + }, + "BulkAddPermissionGroupMembersRequest": { + "type": "object", + "properties": { + "userIds": { + "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", + "minItems": 1, + "maxItems": 1000, "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Value stored when this option is selected." - }, - "label": { - "type": "string", - "description": "Human-readable option label." - } - }, - "required": ["id", "label"], - "additionalProperties": false + "type": "string", + "minLength": 1 } }, - "selectorKey": { - "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", - "type": "string" - }, - "mimeType": { - "description": "MIME type filter applied to the picker.", - "type": "string" - }, - "dependsOn": { - "description": "Sibling fields this field is cleared by when they change.", - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "properties": { - "all": { - "description": "Every listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } - }, - "any": { - "description": "At least one listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - ] - }, - "mode": { - "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "addAllOrganizationMembers": { + "description": "Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Bulk Add Permission Group Members request", + "description": "Bulk Add Permission Group Members inputs.", + "examples": [ + { + "userIds": ["user-123"] + } + ] + }, + "V2PermissionGroupWorkspace": { + "type": "object", + "properties": { + "id": { "type": "string", - "enum": ["basic", "advanced"] + "description": "Workspace identifier." }, - "canonicalParamId": { - "description": "Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.", - "type": "string" - }, - "multi": { - "description": "When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.", - "type": "boolean" + "name": { + "type": "string", + "description": "Workspace display name." } }, - "required": ["id", "title", "type"], + "required": ["id", "name"], "additionalProperties": false, - "title": "Connector config field", - "description": "One field of a knowledge-base connector’s source configuration." + "title": "Permission group workspace", + "description": "An organization workspace available for group targeting." }, - "ListConnectorTypesResponse": { + "ListPermissionGroupWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ConnectorType" + "$ref": "#/components/schemas/V2PermissionGroupWorkspace" }, "description": "Items in the current page." }, @@ -11262,54 +13896,19 @@ "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "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 connector types response", - "description": "Knowledge-base connector types and their configuration fields.", + "title": "List Permission Group Workspaces response", + "description": "List Permission Group Workspaces result.", "examples": [ { "data": [ { - "connectorType": "google_drive", - "name": "Google Drive", - "description": "Sync documents from a Google Drive folder.", - "version": "1.0.0", - "auth": { - "mode": "oauth", - "provider": "google-drive", - "requiredScopes": ["https://www.googleapis.com/auth/drive.readonly"] - }, - "configFields": [ - { - "id": "folderSelector", - "title": "Folder", - "type": "selector", - "selectorKey": "google-drive-folder", - "mimeType": "application/vnd.google-apps.folder", - "mode": "basic", - "canonicalParamId": "folderId", - "required": true - }, - { - "id": "manualFolderId", - "title": "Folder ID", - "type": "short-input", - "placeholder": "Enter the folder ID", - "mode": "advanced", - "canonicalParamId": "folderId" - } - ], - "supportsIncrementalSync": true, - "tagDefinitions": [ - { - "id": "owner", - "displayName": "Owner", - "fieldType": "text" - } - ] + "id": "workspace-123", + "name": "Engineering" } ], "nextCursor": null diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index 5f7257295d2..8d95ae133ae 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -1,211 +1,22 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { member, permissionGroupMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permission-groups' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - findScopeConflicts, - type ScopeConflict, -} from '@/lib/permission-groups/application/group-membership' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' -import { - authorizeOrgAccessControl, - formatScopeConflictError, - getGroupWorkspaces, - loadGroupInOrganization, -} from '@/app/api/organizations/[id]/permission-groups/utils' - -const logger = createLogger('OrganizationPermissionGroupBulkMembers') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await context.params - - // Populated inside the transaction when a scope conflict is detected, so the - // catch can format the 409 after the rollback. - let scopeConflicts: ScopeConflict[] = [] - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const parsed = await parseRequest(bulkAddPermissionGroupMembersContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - const { userIds, addAllOrganizationMembers } = parsed.data.body - - let targetUserIds: string[] = [] - - if (addAllOrganizationMembers) { - const orgMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - targetUserIds = Array.from(new Set(orgMembers.map((m) => m.userId))) - } else if (userIds && userIds.length > 0) { - const uniqueUserIds = Array.from(new Set(userIds)) - const validMembers = await db - .select({ userId: member.userId }) - .from(member) - .where( - and(eq(member.organizationId, organizationId), inArray(member.userId, uniqueUserIds)) - ) - - targetUserIds = Array.from(new Set(validMembers.map((m) => m.userId))) - } - - if (targetUserIds.length === 0) { - return NextResponse.json({ added: 0, skipped: 0 }) - } - - const { addedUserIds } = await db.transaction(async (tx) => { - // Serialize all permission-group writes for this org so the conflict - // check and inserts are atomic against concurrent adds or scope changes. - await acquirePermissionGroupOrgLock(tx, organizationId) - - // Re-read the group under the lock: a concurrent scope change may have - // changed its workspaces since the pre-transaction load, so the conflict - // check uses one consistent snapshot. - const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) - if (!lockedGroup) { - throw new Error('GROUP_NOT_FOUND') - } - - // Bulk add is all-or-nothing for conflicts: if any selected user is - // already an explicit member of another group sharing one of this group's - // workspaces, add nobody and surface the conflict so the admin can fix the - // selection. Members already in this group are no-ops. - const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) - const conflicts = await findScopeConflicts( - { - organizationId, - excludeGroupId: id, - workspaceIds: groupWorkspaceIds, - candidateUserIds: targetUserIds, - }, - tx - ) - if (conflicts.length > 0) { - scopeConflicts = conflicts - throw new Error('SCOPE_CONFLICT') - } - - const existingInGroup = await tx - .select({ userId: permissionGroupMember.userId }) - .from(permissionGroupMember) - .where( - and( - eq(permissionGroupMember.permissionGroupId, id), - inArray(permissionGroupMember.userId, targetUserIds) - ) - ) - const alreadyInThisGroup = new Set(existingInGroup.map((m) => m.userId)) - - const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid)) - - if (usersToAdd.length === 0) { - return { addedUserIds: [] as string[] } - } - - const newMembers = usersToAdd.map((userId) => ({ - id: generateId(), - permissionGroupId: id, - organizationId, - userId, - assignedBy: session.user.id, - assignedAt: new Date(), - })) - - await tx.insert(permissionGroupMember).values(newMembers) - - return { addedUserIds: usersToAdd } - }) - - const skipped = targetUserIds.length - addedUserIds.length - - if (addedUserIds.length === 0) { - return NextResponse.json({ added: 0, skipped }) - } - - logger.info('Bulk added members to permission group', { - permissionGroupId: id, - organizationId, - addedCount: addedUserIds.length, - skipped, - assignedBy: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - resourceName: group.name, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Bulk added ${addedUserIds.length} member(s) to permission group "${group.name}"`, - metadata: { - organizationId, - permissionGroupId: id, - addedUserIds, - skipped, - }, - request: req, - }) - - return NextResponse.json({ added: addedUserIds.length, skipped }) - } catch (error) { - if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'SCOPE_CONFLICT') { - return NextResponse.json( - { error: formatScopeConflictError(scopeConflicts) }, - { status: 409 } - ) - } - if ( - getPostgresErrorCode(error) === '23505' && - getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser - ) { - return NextResponse.json( - { - error: - 'One or more users were concurrently added to this group. Please refresh and try again.', - }, - { status: 409 } - ) - } - // Advisory lock wait exceeded (lock_timeout) — transient contention. - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This group is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - logger.error('Error bulk adding members to permission group', error) - return NextResponse.json({ error: 'Failed to add members' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { bulkAddPermissionGroupMembers } from '@/lib/permission-groups/application/use-cases' + +export const POST = defineInternalJsonRoute({ + contract: bulkAddPermissionGroupMembersContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.bulkAddMembers, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, groupId: params.groupId, ...body }), + useCase: bulkAddPermissionGroupMembers, + present: ({ added, skipped }) => ({ added, skipped }), +}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index b2a6ee6fc42..45f286e6b25 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -1,362 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { permissionGroupMember, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, count, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type AllMembersConflict, - findAllMembersWorkspaceConflict, - findScopeConflicts, - type ScopeConflict, -} from '@/lib/permission-groups/application/group-membership' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' -import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' + addPermissionGroupMemberContract, + listPermissionGroupMembersContract, + removePermissionGroupMemberContract, +} from '@/lib/api/contracts/permission-groups' +import { presentPermissionGroupMember } from '@/lib/api/server/permission-group-presenters' import { - authorizeOrgAccessControl, - formatAllMembersConflictError, - formatScopeConflictError, - getGroupWorkspaces, - loadGroupInOrganization, -} from '@/app/api/organizations/[id]/permission-groups/utils' - -const logger = createLogger('OrganizationPermissionGroupMembers') - -export const GET = withRouteHandler( - async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await params - - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const members = await db - .select({ - id: permissionGroupMember.id, - userId: permissionGroupMember.userId, - assignedAt: permissionGroupMember.assignedAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(permissionGroupMember) - .leftJoin(user, eq(permissionGroupMember.userId, user.id)) - .where(eq(permissionGroupMember.permissionGroupId, id)) - - return NextResponse.json({ members }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await context.params - - // Populated inside the transaction when a scope conflict is detected, so the - // catch can format the 409 after the rollback. - let scopeConflicts: ScopeConflict[] = [] - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const parsed = await parseRequest(addPermissionGroupMemberContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - const { userId } = parsed.data.body - - const isMember = await isOrganizationMember(userId, organizationId) - if (!isMember) { - return NextResponse.json( - { error: 'User is not a member of this organization' }, - { status: 400 } - ) - } - - const newMember = await db.transaction(async (tx) => { - // Serialize all permission-group writes for this org so the conflict - // check and insert are atomic. Without it, two concurrent adds (or a - // concurrent scope change) could both pass findScopeConflicts and place - // the user in two groups that overlap on a workspace. - await acquirePermissionGroupOrgLock(tx, organizationId) - - // Re-read the group under the lock: a concurrent scope change may have - // changed its workspaces since the pre-transaction load, so the conflict - // check uses one consistent snapshot. - const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) - if (!lockedGroup) { - throw new Error('GROUP_NOT_FOUND') - } - - const [existingInGroup] = await tx - .select({ id: permissionGroupMember.id }) - .from(permissionGroupMember) - .where( - and( - eq(permissionGroupMember.permissionGroupId, id), - eq(permissionGroupMember.userId, userId) - ) - ) - .limit(1) - - if (existingInGroup) { - throw new Error('ALREADY_IN_GROUP') - } - - // A user may belong to multiple groups, but only one may govern any given - // workspace. Reject when the user is already an explicit member of another - // group that shares one of this group's workspaces. - const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) - const conflicts = await findScopeConflicts( - { - organizationId, - excludeGroupId: id, - workspaceIds: groupWorkspaceIds, - candidateUserIds: [userId], - }, - tx - ) - if (conflicts.length > 0) { - scopeConflicts = conflicts - throw new Error('SCOPE_CONFLICT') - } - - const memberData = { - id: generateId(), - permissionGroupId: id, - organizationId, - userId, - assignedBy: session.user.id, - assignedAt: new Date(), - } - - await tx.insert(permissionGroupMember).values(memberData) - return memberData - }) - - logger.info('Added member to permission group', { - permissionGroupId: id, - organizationId, - userId, - assignedBy: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - resourceName: group.name, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Added member ${userId} to permission group "${group.name}"`, - metadata: { - organizationId, - targetUserId: userId, - permissionGroupId: id, - }, - request: req, - }) - - return NextResponse.json({ member: newMember }, { status: 201 }) - } catch (error) { - if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'ALREADY_IN_GROUP') { - return NextResponse.json( - { error: 'User is already in this permission group' }, - { status: 409 } - ) - } - if (error instanceof Error && error.message === 'SCOPE_CONFLICT') { - return NextResponse.json( - { error: formatScopeConflictError(scopeConflicts) }, - { status: 409 } - ) - } - if ( - getPostgresErrorCode(error) === '23505' && - getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser - ) { - return NextResponse.json( - { error: 'User is already in this permission group' }, - { status: 409 } - ) - } - // Advisory lock wait exceeded (lock_timeout) — transient contention. - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This group is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - logger.error('Error adding member to permission group', error) - return NextResponse.json({ error: 'Failed to add member' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await params - const { searchParams } = new URL(req.url) - const memberId = searchParams.get('memberId') - - if (!memberId) { - return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) - } - - // Populated inside the transaction when an all-members scope conflict is - // detected, so the catch can format the 409 after the rollback. - let allMembersConflict: AllMembersConflict | null = null - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const memberToRemove = await db.transaction(async (tx) => { - // Serialize permission-group writes for this org so the last-member check - // and the delete commit atomically: removing the last member turns a - // workspace group into an all-members group, which is unique per workspace. - await acquirePermissionGroupOrgLock(tx, organizationId) - - const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) - if (!lockedGroup) { - throw new Error('GROUP_NOT_FOUND') - } - - const [member] = await tx - .select({ - id: permissionGroupMember.id, - userId: permissionGroupMember.userId, - email: user.email, - }) - .from(permissionGroupMember) - .innerJoin(user, eq(permissionGroupMember.userId, user.id)) - .where( - and( - eq(permissionGroupMember.id, memberId), - eq(permissionGroupMember.permissionGroupId, id) - ) - ) - .limit(1) - - if (!member) { - throw new Error('MEMBER_NOT_FOUND') - } - - if (!lockedGroup.isDefault && lockedGroup.membershipMode === 'inherit') { - const [memberCountRow] = await tx - .select({ value: count() }) - .from(permissionGroupMember) - .where(eq(permissionGroupMember.permissionGroupId, id)) - if ((memberCountRow?.value ?? 0) <= 1) { - const workspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) - const conflict = await findAllMembersWorkspaceConflict( - { organizationId, excludeGroupId: id, workspaceIds }, - tx - ) - if (conflict) { - allMembersConflict = conflict - throw new Error('ALL_MEMBERS_CONFLICT') - } - } - } - - await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) - return member - }) - - logger.info('Removed member from permission group', { - permissionGroupId: id, - organizationId, - memberId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - resourceName: group.name, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Removed member ${memberToRemove.userId} from permission group "${group.name}"`, - metadata: { - organizationId, - targetUserId: memberToRemove.userId, - targetEmail: memberToRemove.email ?? undefined, - memberId, - permissionGroupId: id, - }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'MEMBER_NOT_FOUND') { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - if ( - error instanceof Error && - error.message === 'ALL_MEMBERS_CONFLICT' && - allMembersConflict - ) { - return NextResponse.json( - { error: formatAllMembersConflictError(allMembersConflict) }, - { status: 409 } - ) - } - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This group is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - logger.error('Error removing member from permission group', error) - return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { + addPermissionGroupMember, + listPermissionGroupMembers, + removePermissionGroupMember, +} from '@/lib/permission-groups/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: listPermissionGroupMembersContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.listMembers, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, groupId: params.groupId }), + useCase: listPermissionGroupMembers, + present: ({ data }) => ({ members: data.map(presentPermissionGroupMember) }), +}) + +export const POST = defineInternalJsonRoute({ + contract: addPermissionGroupMemberContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.addMember, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + groupId: params.groupId, + userId: body.userId, + }), + useCase: addPermissionGroupMember, + present: ({ member }) => ({ member: presentPermissionGroupMember(member) }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: removePermissionGroupMemberContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.removeMember, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + groupId: params.groupId, + memberId: query.memberId, + }), + useCase: removePermissionGroupMember, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts index 02948dc1ee5..b732750d5ca 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts @@ -28,12 +28,9 @@ vi.mock('@/lib/permission-groups/application/group-membership', () => ({ findScopeConflicts: vi.fn(), })) -vi.mock('@/app/api/organizations/[id]/permission-groups/utils', () => ({ - authorizeOrgAccessControl: mocks.authorize, +vi.mock('@/lib/permission-groups/repository', () => ({ loadGroupInOrganization: mocks.loadGroup, findWorkspacesNotInOrganization: vi.fn(), - formatAllMembersConflictError: vi.fn(), - formatScopeConflictError: vi.fn(), getGroupWorkspaces: vi.fn(), })) @@ -43,6 +40,13 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: { PERMISSION_GROUP: 'permission_group' }, })) +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mocks.authorize, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: vi.fn().mockResolvedValue(true), +})) + import { PUT } from '@/app/api/organizations/[id]/permission-groups/[groupId]/route' const ORGANIZATION_ID = 'org-1' @@ -54,6 +58,10 @@ const GROUP = { description: null, isDefault: true, config: { disableOAuthAppAccess: false }, + membershipMode: 'inherit', + createdBy: 'admin-1', + createdAt: new Date(), + updatedAt: new Date(), } async function updateUnderLock(body: UpdatePermissionGroupBody) { @@ -86,13 +94,22 @@ describe('permission group PUT policy serialization', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) - mocks.authorize.mockResolvedValue(null) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.authorize.mockResolvedValue({ + userId: 'admin-1', + organizationId: ORGANIZATION_ID, + role: 'admin', + }) mocks.loadGroup.mockResolvedValue(GROUP) }) it('locks a config-only update and writes the requested OAuth restriction', async () => { - queueTableRows(permissionGroup, [{ ...GROUP, config: { disableOAuthAppAccess: true } }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...GROUP, config: { disableOAuthAppAccess: true } }, + ]) await updateUnderLock({ config: { disableOAuthAppAccess: true } }) @@ -105,7 +122,7 @@ describe('permission group PUT policy serialization', () => { 'locks metadata-only update %j without restoring stale policy', async (metadata) => { if ('name' in metadata) queueTableRows(permissionGroup, []) - queueTableRows(permissionGroup, [ + dbChainMockFns.returning.mockResolvedValueOnce([ { ...GROUP, ...metadata, config: { disableOAuthAppAccess: true } }, ]) @@ -120,10 +137,8 @@ describe('permission group PUT policy serialization', () => { ) it('merges a config patch with the policy reloaded under the lock', async () => { - mocks.loadGroup - .mockResolvedValueOnce(GROUP) - .mockResolvedValueOnce({ ...GROUP, config: { disableOAuthAppAccess: true } }) - queueTableRows(permissionGroup, [ + mocks.loadGroup.mockResolvedValueOnce({ ...GROUP, config: { disableOAuthAppAccess: true } }) + dbChainMockFns.returning.mockResolvedValueOnce([ { ...GROUP, config: { disableOAuthAppAccess: true, disableCliAccess: true } }, ]) @@ -136,8 +151,8 @@ describe('permission group PUT policy serialization', () => { ) }) - it('does not write when the group disappears before the locked reload', async () => { - mocks.loadGroup.mockResolvedValueOnce(GROUP).mockResolvedValueOnce(null) + it('does not write when the group disappears before the locked read', async () => { + mocks.loadGroup.mockResolvedValueOnce(null) mocks.acquireLock.mockResolvedValueOnce(undefined) const response = await PUT(createMockRequest('PUT', { description: 'Updated description' }), { @@ -145,7 +160,7 @@ describe('permission group PUT policy serialization', () => { }) expect(response.status).toBe(404) - await expect(response.json()).resolves.toEqual({ error: 'Permission group not found' }) + await expect(response.json()).resolves.toMatchObject({ error: 'Permission group not found' }) expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) expect(mocks.loadGroup).toHaveBeenLastCalledWith(GROUP_ID, ORGANIZATION_ID, db) expect(dbChainMockFns.update).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index a245f86f430..f8b14d440ac 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -1,409 +1,61 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type AllMembersConflict, - findAllMembersWorkspaceConflict, - findScopeConflicts, - type ScopeConflict, -} from '@/lib/permission-groups/application/group-membership' -import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' + deletePermissionGroupContract, + getPermissionGroupContract, + updatePermissionGroupContract, +} from '@/lib/api/contracts/permission-groups' +import { presentPermissionGroup } from '@/lib/api/server/permission-group-presenters' import { - type PermissionGroupConfig, - parsePermissionGroupConfig, -} from '@/lib/permission-groups/fields' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' import { - authorizeOrgAccessControl, - findWorkspacesNotInOrganization, - formatAllMembersConflictError, - formatScopeConflictError, - getGroupWorkspaces, - loadGroupInOrganization, -} from '@/app/api/organizations/[id]/permission-groups/utils' - -const logger = createLogger('OrganizationPermissionGroup') - -export const GET = withRouteHandler( - async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await params - - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const workspaces = group.isDefault ? [] : await getGroupWorkspaces(id) - - return NextResponse.json({ - permissionGroup: { - ...group, - config: parsePermissionGroupConfig(group.config), - workspaces, - }, - }) - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await context.params - - // Populated inside the transaction when a scope conflict is detected, so the - // catch can format the 409 after the rollback. - let scopeConflicts: ScopeConflict[] = [] - let allMembersConflict: AllMembersConflict | null = null - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - const parsed = await parseRequest(updatePermissionGroupContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - const updates = parsed.data.body - - if (updates.name) { - const existingGroup = await db - .select({ id: permissionGroup.id }) - .from(permissionGroup) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.name, updates.name) - ) - ) - .limit(1) - - if (existingGroup.length > 0 && existingGroup[0].id !== id) { - return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } - ) - } - } - - // Demoting the org default with no new scope: it becomes a non-default - // group with no workspaces (inert) until an admin re-scopes it. The client - // sends only `isDefault: false`, so this never forwards a workspace list. - const demotingDefaultToInert = - group.isDefault && updates.isDefault === false && updates.workspaceIds === undefined - - // "Org-wide" is definitionally `isDefault` (the default group), so the - // effective scope follows it: a default group targets no specific - // workspaces; a non-default group targets its `workspaceIds`. - const effectiveIsDefault = - updates.isDefault !== undefined ? updates.isDefault : group.isDefault - - // Scope is rewritten when the group is promoted to default, demoted to - // inert, or handed an explicit workspace list. - const scopeProvided = - demotingDefaultToInert || updates.workspaceIds !== undefined || updates.isDefault === true - - // The default group governs every workspace, so it can't also name specific - // ones. The contract rejects `isDefault: true` + workspaceIds, but a direct - // API caller can still send workspaceIds against a group that is already the - // default — reject rather than silently dropping them. - if (effectiveIsDefault && updates.workspaceIds !== undefined) { - return NextResponse.json( - { - error: 'The default group governs all workspaces and cannot target specific workspaces', - }, - { status: 400 } - ) - } - - // Resolve and validate explicitly-provided workspaceIds before the - // transaction. When the request omits them for a specific-scope group - // ("keep current"), they're read under the lock instead (see below) so the - // conflict check and the write share one consistent snapshot. - let providedWorkspaceIds: string[] | null = null - if (!effectiveIsDefault && updates.workspaceIds !== undefined) { - // Zero workspaces is allowed on update: the group then governs nothing - // (the resolver inner-joins on the link table, so an empty group never - // matches any workspace). No "at least one" floor here. - providedWorkspaceIds = Array.from(new Set(updates.workspaceIds)) - const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId) - if (invalid.length > 0) { - return NextResponse.json( - { error: 'One or more selected workspaces do not belong to this organization' }, - { status: 400 } - ) - } - } - - const now = new Date() - - await db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) - const currentGroup = await loadGroupInOrganization(id, organizationId, tx) - if (!currentGroup) throw new Error('GROUP_NOT_FOUND') - const newConfig: PermissionGroupConfig | undefined = updates.config - ? { ...parsePermissionGroupConfig(currentGroup.config), ...updates.config } - : undefined - - // For a specific-scope group the target workspaces are the request's - // explicit ids, or — when omitted ("keep current") — the group's current - // workspaces read under the lock so the conflict check and write share - // one snapshot. - let resolvedWorkspaceIds: string[] = [] - - if (scopeProvided) { - if (!effectiveIsDefault) { - // May resolve to an empty list — a non-default group is allowed to - // target zero workspaces (governs nothing). The write below deletes - // the old links and inserts none. - resolvedWorkspaceIds = - providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) - } - - const members = await tx - .select({ userId: permissionGroupMember.userId }) - .from(permissionGroupMember) - .where(eq(permissionGroupMember.permissionGroupId, id)) - const conflicts = await findScopeConflicts( - { - organizationId, - excludeGroupId: id, - workspaceIds: resolvedWorkspaceIds, - candidateUserIds: members.map((m) => m.userId), - }, - tx - ) - if (conflicts.length > 0) { - scopeConflicts = conflicts - throw new Error('SCOPE_CONFLICT') - } - - // With no explicit members the group governs all members of its - // workspaces; reject when another all-members group already does. - if (!effectiveIsDefault && members.length === 0) { - const conflict = await findAllMembersWorkspaceConflict( - { organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds }, - tx - ) - if (conflict) { - allMembersConflict = conflict - throw new Error('ALL_MEMBERS_CONFLICT') - } - } - } - - if (updates.isDefault === true) { - // Demote the prior default to a non-default group (only the default may - // be org-wide); it ends up with no workspaces (inert) until an admin - // re-scopes it. - await tx - .update(permissionGroup) - .set({ isDefault: false, updatedAt: now }) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.isDefault, true) - ) - ) - } - - await tx - .update(permissionGroup) - .set({ - ...(updates.name !== undefined && { name: updates.name }), - ...(updates.description !== undefined && { description: updates.description }), - ...(updates.isDefault !== undefined && { isDefault: updates.isDefault }), - ...(newConfig !== undefined && { config: newConfig }), - updatedAt: now, - }) - .where(eq(permissionGroup.id, id)) - - if (scopeProvided) { - await tx - .delete(permissionGroupWorkspace) - .where(eq(permissionGroupWorkspace.permissionGroupId, id)) - if (!effectiveIsDefault && resolvedWorkspaceIds.length > 0) { - await tx.insert(permissionGroupWorkspace).values( - resolvedWorkspaceIds.map((workspaceId) => ({ - id: generateId(), - permissionGroupId: id, - workspaceId, - organizationId, - createdAt: now, - })) - ) - } - } - }) - - const [updated] = await db - .select() - .from(permissionGroup) - .where(eq(permissionGroup.id, id)) - .limit(1) - - const finalWorkspaceIds = updated.isDefault - ? [] - : (await getGroupWorkspaces(id)).map((ws) => ws.id) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_UPDATED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updated.name, - description: `Updated permission group "${updated.name}"`, - metadata: { - organizationId, - updatedFields: Object.keys(updates).filter( - (k) => updates[k as keyof typeof updates] !== undefined - ), - }, - request: req, - }) - - return NextResponse.json({ - permissionGroup: { - ...updated, - config: parsePermissionGroupConfig(updated.config), - workspaceIds: finalWorkspaceIds, - }, - }) - } catch (error) { - if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'SCOPE_CONFLICT') { - return NextResponse.json( - { error: formatScopeConflictError(scopeConflicts) }, - { status: 409 } - ) - } - if ( - error instanceof Error && - error.message === 'ALL_MEMBERS_CONFLICT' && - allMembersConflict - ) { - return NextResponse.json( - { error: formatAllMembersConflictError(allMembersConflict) }, - { status: 409 } - ) - } - if (getPostgresErrorCode(error) === '23505') { - const constraint = getPostgresConstraintName(error) - if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) { - return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } - ) - } - if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) { - return NextResponse.json( - { - error: - 'Another group was concurrently set as the default. Please refresh and try again.', - }, - { status: 409 } - ) - } - } - // Advisory lock wait exceeded (lock_timeout) — transient contention. - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This group is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - logger.error('Error updating permission group', error) - return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, groupId: id } = await params - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const group = await loadGroupInOrganization(id, organizationId) - if (!group) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - await db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) - await tx - .delete(permissionGroupMember) - .where(eq(permissionGroupMember.permissionGroupId, id)) - await tx.delete(permissionGroup).where(eq(permissionGroup.id, id)) - }) - - logger.info('Deleted permission group', { - permissionGroupId: id, - organizationId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_DELETED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: group.name, - description: `Deleted permission group "${group.name}"`, - metadata: { organizationId }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - // Advisory lock wait exceeded (lock_timeout) — transient contention. - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This group is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - logger.error('Error deleting permission group', error) - return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 }) - } - } -) + deletePermissionGroup, + getPermissionGroup, + updatePermissionGroup, +} from '@/lib/permission-groups/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: getPermissionGroupContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, groupId: params.groupId }), + useCase: getPermissionGroup, + present: (group) => ({ permissionGroup: presentPermissionGroup(group) }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updatePermissionGroupContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + groupId: params.groupId, + changes: body, + }), + useCase: updatePermissionGroup, + present: (group) => ({ permissionGroup: presentPermissionGroup(group) }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deletePermissionGroupContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, groupId: params.groupId }), + useCase: deletePermissionGroup, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index 42cfc1ae1a4..d251c10d8a8 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -1,282 +1,42 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' import { - permissionGroup, - permissionGroupMember, - permissionGroupWorkspace, - user, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, count, desc, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createPermissionGroupContract } from '@/lib/api/contracts/permission-groups' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + createPermissionGroupContract, + listPermissionGroupsContract, +} from '@/lib/api/contracts/permission-groups' +import { presentPermissionGroup } from '@/lib/api/server/permission-group-presenters' import { - type AllMembersConflict, - findAllMembersWorkspaceConflict, -} from '@/lib/permission-groups/application/group-membership' -import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' import { - DEFAULT_PERMISSION_GROUP_CONFIG, - type PermissionGroupConfig, - parsePermissionGroupConfig, -} from '@/lib/permission-groups/fields' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' -import { - authorizeOrgAccessControl, - findWorkspacesNotInOrganization, - formatAllMembersConflictError, - getWorkspacesForGroups, -} from '@/app/api/organizations/[id]/permission-groups/utils' - -const logger = createLogger('OrganizationPermissionGroups') - -export const GET = withRouteHandler( - async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId } = await params - - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const groups = await db - .select({ - id: permissionGroup.id, - name: permissionGroup.name, - description: permissionGroup.description, - config: permissionGroup.config, - createdBy: permissionGroup.createdBy, - createdAt: permissionGroup.createdAt, - updatedAt: permissionGroup.updatedAt, - isDefault: permissionGroup.isDefault, - creatorName: user.name, - creatorEmail: user.email, - }) - .from(permissionGroup) - .leftJoin(user, eq(permissionGroup.createdBy, user.id)) - .where(eq(permissionGroup.organizationId, organizationId)) - .orderBy(desc(permissionGroup.createdAt)) - - const groupIds = groups.map((group) => group.id) - const memberCounts = groupIds.length - ? await db - .select({ - permissionGroupId: permissionGroupMember.permissionGroupId, - count: count(), - }) - .from(permissionGroupMember) - .where(inArray(permissionGroupMember.permissionGroupId, groupIds)) - .groupBy(permissionGroupMember.permissionGroupId) - : [] - const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count])) - const workspacesByGroupId = await getWorkspacesForGroups(groupIds) - - const groupsWithCounts = groups.map((group) => ({ - ...group, - config: parsePermissionGroupConfig(group.config), - memberCount: countByGroupId.get(group.id) ?? 0, - workspaces: workspacesByGroupId.get(group.id) ?? [], - })) - - return NextResponse.json({ permissionGroups: groupsWithCounts }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId } = await context.params - - // Populated inside the transaction when an all-members scope conflict is - // detected, so the catch can format the 409 after the rollback. - let allMembersConflict: AllMembersConflict | null = null - - try { - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const parsed = await parseRequest(createPermissionGroupContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - const { name, description, config, isDefault } = parsed.data.body - - // Only the organization default group is org-wide; every other group - // targets specific workspaces. "Org-wide" is definitionally `isDefault`. - const isDefaultGroup = isDefault === true - const workspaceIds = isDefaultGroup - ? [] - : Array.from(new Set(parsed.data.body.workspaceIds ?? [])) - - if (!isDefaultGroup && workspaceIds.length === 0) { - return NextResponse.json( - { error: 'Select at least one workspace when the group targets specific workspaces' }, - { status: 400 } - ) - } - - if (!isDefaultGroup) { - const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId) - if (invalid.length > 0) { - return NextResponse.json( - { error: 'One or more selected workspaces do not belong to this organization' }, - { status: 400 } - ) - } - } - - const existingGroup = await db - .select({ id: permissionGroup.id }) - .from(permissionGroup) - .where( - and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name)) - ) - .limit(1) - - if (existingGroup.length > 0) { - return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } - ) - } - - const groupConfig: PermissionGroupConfig = { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - ...config, - } - - const now = new Date() - const newGroup = { - id: generateId(), - organizationId, - name, - description: description || null, - config: groupConfig, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - isDefault: isDefault || false, - } - - await db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) - - // A new non-default group has no members, so it governs all members of - // its workspaces; reject when another all-members group already does. - if (!isDefaultGroup) { - const conflict = await findAllMembersWorkspaceConflict( - { organizationId, excludeGroupId: newGroup.id, workspaceIds }, - tx - ) - if (conflict) { - allMembersConflict = conflict - throw new Error('ALL_MEMBERS_CONFLICT') - } - } - - if (isDefault) { - // Demote the prior default to a non-default group (only the default may - // be org-wide); it ends up with no workspaces (inert) until an admin - // re-scopes it. - await tx - .update(permissionGroup) - .set({ isDefault: false, updatedAt: now }) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.isDefault, true) - ) - ) - } - await tx.insert(permissionGroup).values(newGroup) - if (workspaceIds.length > 0) { - await tx.insert(permissionGroupWorkspace).values( - workspaceIds.map((workspaceId) => ({ - id: generateId(), - permissionGroupId: newGroup.id, - workspaceId, - organizationId, - createdAt: now, - })) - ) - } - }) - - logger.info('Created permission group', { - permissionGroupId: newGroup.id, - organizationId, - userId: session.user.id, - workspaceCount: workspaceIds.length, - }) - - recordAudit({ - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_CREATED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: newGroup.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created permission group "${name}"`, - metadata: { - organizationId, - isDefault: isDefault || false, - workspaceCount: workspaceIds.length, - }, - request: req, - }) - - return NextResponse.json({ permissionGroup: { ...newGroup, workspaceIds } }, { status: 201 }) - } catch (error) { - if ( - error instanceof Error && - error.message === 'ALL_MEMBERS_CONFLICT' && - allMembersConflict - ) { - return NextResponse.json( - { error: formatAllMembersConflictError(allMembersConflict) }, - { status: 409 } - ) - } - if (getPostgresErrorCode(error) === '55P03') { - return NextResponse.json( - { error: 'This organization is being updated by another request. Please try again.' }, - { status: 503 } - ) - } - if (getPostgresErrorCode(error) === '23505') { - const constraint = getPostgresConstraintName(error) - if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) { - return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } - ) - } - if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) { - return NextResponse.json( - { - error: - 'Another group was concurrently set as the default. Please refresh and try again.', - }, - { status: 409 } - ) - } - } - logger.error('Error creating permission group', error) - return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 }) - } - } -) + createPermissionGroup, + listPermissionGroups, +} from '@/lib/permission-groups/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: listPermissionGroupsContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: listPermissionGroups, + present: ({ data }) => ({ permissionGroups: data.map(presentPermissionGroup) }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createPermissionGroupContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.create, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, changes: body }), + useCase: createPermissionGroup, + present: (group) => ({ permissionGroup: presentPermissionGroup(group) }), +}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts deleted file mode 100644 index c426e067815..00000000000 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @vitest-environment node - */ -import { resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted( - () => ({ - mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise>(), - }) -) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, -})) - -import { authorizeOrgAccessControl } from '@/app/api/organizations/[id]/permission-groups/utils' - -afterAll(resetDbChainMock) - -describe('authorizeOrgAccessControl', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('returns a 403 when the user is not an organization admin/owner', async () => { - mockIsOrganizationAdminOrOwner.mockResolvedValue(false) - mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) - - const response = await authorizeOrgAccessControl('user-1', 'org-1') - - expect(response).not.toBeNull() - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' }) - // Entitlement is only checked after the admin gate passes. - expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled() - }) - - it('returns a 403 when the organization is not on an enterprise plan', async () => { - mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false) - - const response = await authorizeOrgAccessControl('user-1', 'org-1') - - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: 'Access Control is an Enterprise feature', - }) - }) - - it('returns null when the user is an admin and the org is entitled', async () => { - mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) - - const response = await authorizeOrgAccessControl('user-1', 'org-1') - - expect(response).toBeNull() - }) -}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts deleted file mode 100644 index 026963a3a5a..00000000000 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { db } from '@sim/db' -import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema' -import { and, asc, eq, inArray } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import type { DbOrTx } from '@/lib/db/types' -import type { - AllMembersConflict, - ScopeConflict, -} from '@/lib/permission-groups/application/group-membership' -import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' -import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' - -/** A workspace reference (id + display name). */ -export interface WorkspaceRef { - id: string - name: string -} - -/** - * Authorize an organization-scoped access-control management request. The caller - * must be an organization owner/admin and the organization must be entitled to - * the Access Control (Permission Groups) enterprise feature. Returns a - * `NextResponse` to short-circuit on failure, or `null` when authorized. - */ -export async function authorizeOrgAccessControl( - userId: string, - organizationId: string -): Promise { - const isAdmin = await isOrganizationAdminOrOwner(userId, organizationId) - if (!isAdmin) { - return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) - } - - /** - * The active permission regime, which is what the Access Control page now reads too: an - * organization whose restrictions still apply has to be able to see and loosen them, and a - * deployment with Access Control switched off governs nobody, so neither should manage anything. - */ - const governed = await isOrganizationPermissionRegimeActive(organizationId) - if (!governed) { - return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 }) - } - - return null -} - -/** Load a permission group only if it belongs to the given organization. */ -export async function loadGroupInOrganization( - groupId: string, - organizationId: string, - executor: DbOrTx = db -) { - const [group] = await executor - .select({ - id: permissionGroup.id, - organizationId: permissionGroup.organizationId, - name: permissionGroup.name, - description: permissionGroup.description, - config: permissionGroup.config, - createdBy: permissionGroup.createdBy, - createdAt: permissionGroup.createdAt, - updatedAt: permissionGroup.updatedAt, - isDefault: permissionGroup.isDefault, - membershipMode: permissionGroup.membershipMode, - }) - .from(permissionGroup) - .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) - .limit(1) - - return group ?? null -} - -/** The workspaces ({id, name}) a specific-scope group targets. */ -export async function getGroupWorkspaces( - groupId: string, - executor: DbOrTx = db -): Promise { - return executor - .select({ id: workspace.id, name: workspace.name }) - .from(permissionGroupWorkspace) - .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) - .where(eq(permissionGroupWorkspace.permissionGroupId, groupId)) - .orderBy(asc(workspace.name)) -} - -/** Batched map of `groupId -> targeted workspaces` for a list of groups. */ -export async function getWorkspacesForGroups( - groupIds: string[] -): Promise> { - const byGroup = new Map() - if (groupIds.length === 0) return byGroup - - const rows = await db - .select({ - groupId: permissionGroupWorkspace.permissionGroupId, - id: workspace.id, - name: workspace.name, - }) - .from(permissionGroupWorkspace) - .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) - .where(inArray(permissionGroupWorkspace.permissionGroupId, groupIds)) - .orderBy(asc(workspace.name)) - - for (const row of rows) { - const list = byGroup.get(row.groupId) ?? [] - list.push({ id: row.id, name: row.name }) - byGroup.set(row.groupId, list) - } - return byGroup -} - -/** Returns the subset of `workspaceIds` that do NOT belong to the organization. */ -export async function findWorkspacesNotInOrganization( - workspaceIds: string[], - organizationId: string -): Promise { - if (workspaceIds.length === 0) return [] - const rows = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(inArray(workspace.id, workspaceIds), eq(workspace.organizationId, organizationId))) - const valid = new Set(rows.map((row) => row.id)) - return workspaceIds.filter((id) => !valid.has(id)) -} - -/** List an organization's workspaces ({id, name}), ordered by name. */ -export async function listOrganizationWorkspaces(organizationId: string): Promise { - return db - .select({ id: workspace.id, name: workspace.name }) - .from(workspace) - .where(eq(workspace.organizationId, organizationId)) - .orderBy(asc(workspace.name)) -} - -/** A member whose other group membership would conflict with a candidate scope. */ -/** - * Human-readable 409 message for a scope/membership conflict, naming the member - * and the group they already belong to that overlaps the requested workspaces. - */ -export function formatScopeConflictError(conflicts: ScopeConflict[]): string { - const [first] = conflicts - if (!first) { - return 'A member would be governed by two groups for the same workspace. Resolve their group memberships first.' - } - const who = first.userName || first.userEmail || 'A member' - if (conflicts.length === 1) { - return `${who} is already in the group "${first.conflictingGroupName}", which targets one of these workspaces. Remove them from one group first.` - } - const others = conflicts.length - 1 - return `${who} and ${others} other member${others === 1 ? '' : 's'} already belong to groups that target these workspaces (e.g. "${first.conflictingGroupName}"). Resolve their group memberships first.` -} - -/** - * Human-readable 409 message when another group already governs everyone in a - * workspace this group would also apply to all members of. - */ -export function formatAllMembersConflictError(conflict: AllMembersConflict): string { - return `The group "${conflict.conflictingGroupName}" already applies to everyone in "${conflict.workspaceName}". Two groups can't both govern all members of the same workspace — add members to one of them, or remove that workspace from one group first.` -} diff --git a/apps/sim/app/api/organizations/[id]/workspaces/route.ts b/apps/sim/app/api/organizations/[id]/workspaces/route.ts index eefd4bff91a..def26ee7330 100644 --- a/apps/sim/app/api/organizations/[id]/workspaces/route.ts +++ b/apps/sim/app/api/organizations/[id]/workspaces/route.ts @@ -1,45 +1,22 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { listOrganizationWorkspacesContract } from '@/lib/api/contracts/permission-groups' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - authorizeOrgAccessControl, - listOrganizationWorkspaces, -} from '@/app/api/organizations/[id]/permission-groups/utils' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { listPermissionGroupWorkspaces } from '@/lib/permission-groups/application/use-cases' -const logger = createLogger('OrganizationWorkspaces') - -/** - * GET /api/organizations/[id]/workspaces - * - * Lists the workspaces belonging to an organization, used to populate the - * workspace multi-select when scoping a permission group. Gated to organization - * owners/admins on an Enterprise-entitled organization (same gate as the - * permission-group management routes). - */ -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(listOrganizationWorkspacesContract, req, context) - if (!parsed.success) return parsed.response - const { id: organizationId } = parsed.data.params - - const denied = await authorizeOrgAccessControl(session.user.id, organizationId) - if (denied) return denied - - const workspaces = await listOrganizationWorkspaces(organizationId) - - logger.info('Listed organization workspaces', { - organizationId, - count: workspaces.length, - }) - - return NextResponse.json({ workspaces }) - } -) +export const GET = defineInternalJsonRoute({ + contract: listOrganizationWorkspacesContract, + auth: internalSessionAuth, + operation: permissionGroupOperations.listWorkspaces, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing permission group settings behavior', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: listPermissionGroupWorkspaces, + present: ({ data }) => ({ workspaces: data }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts new file mode 100644 index 00000000000..8359515e81e --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts @@ -0,0 +1,16 @@ +import { v2RemovePermissionGroupMemberContract } from '@/lib/api/contracts/v2/permission-groups' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { removePermissionGroupMember } from '@/lib/permission-groups/application/use-cases' + +export const DELETE = defineV2JsonRoute({ + contract: v2RemovePermissionGroupMemberContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.removeMember, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params }) => params, + useCase: removePermissionGroupMember, + present: ({ member }) => ({ data: { id: member.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route.ts new file mode 100644 index 00000000000..7d0eeb3e8c6 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route.ts @@ -0,0 +1,16 @@ +import { v2BulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/v2/permission-groups' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { bulkAddPermissionGroupMembers } from '@/lib/permission-groups/application/use-cases' + +export const POST = defineV2JsonRoute({ + contract: v2BulkAddPermissionGroupMembersContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.bulkAddMembers, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: bulkAddPermissionGroupMembers, + present: ({ added, skipped }) => ({ data: { added, skipped } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route.ts new file mode 100644 index 00000000000..10153d875e2 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route.ts @@ -0,0 +1,57 @@ +import { + v2AddPermissionGroupMemberContract, + v2ListPermissionGroupMembersContract, +} from '@/lib/api/contracts/v2/permission-groups' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { presentPermissionGroupMember } from '@/lib/api/server/permission-group-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { + addPermissionGroupMember, + listPermissionGroupMembers, +} from '@/lib/permission-groups/application/use-cases' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters(params: { organizationId: string; groupId: string }) { + return cursorScopeKey(cursorRoute(v2ListPermissionGroupMembersContract, params), {}) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListPermissionGroupMembersContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.listMembers, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params) + ), + }), + useCase: listPermissionGroupMembers, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data.map(presentPermissionGroupMember), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorFilters(params) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2AddPermissionGroupMemberContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.addMember, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: addPermissionGroupMember, + present: ({ member }) => ({ data: presentPermissionGroupMember(member) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route.ts new file mode 100644 index 00000000000..c9bec039f3b --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route.ts @@ -0,0 +1,47 @@ +import { + v2DeletePermissionGroupContract, + v2GetPermissionGroupContract, + v2UpdatePermissionGroupContract, +} from '@/lib/api/contracts/v2/permission-groups' +import { presentPermissionGroup } from '@/lib/api/server/permission-group-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { + deletePermissionGroup, + getPermissionGroup, + updatePermissionGroup, +} from '@/lib/permission-groups/application/use-cases' + +export const GET = defineV2JsonRoute({ + contract: v2GetPermissionGroupContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params }) => params, + useCase: getPermissionGroup, + present: (group) => ({ data: presentPermissionGroup(group) }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdatePermissionGroupContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.update, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, changes: body }), + useCase: updatePermissionGroup, + present: (group) => ({ data: presentPermissionGroup(group) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeletePermissionGroupContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params }) => params, + useCase: deletePermissionGroup, + present: (group) => ({ data: { id: group.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.test.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.test.ts new file mode 100644 index 00000000000..f7722a5a628 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.test.ts @@ -0,0 +1,373 @@ +/** @vitest-environment node */ +import { member, permissionGroup } from '@sim/db/schema' +import { authMockFns, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + class Unauthenticated extends Error {} + return { + authenticate: vi.fn(), + preauth: vi.fn(), + rate: vi.fn(), + regime: vi.fn(), + config: vi.fn(), + lock: vi.fn(), + group: vi.fn(), + workspaces: vi.fn(), + groupWorkspaces: vi.fn(), + conflict: vi.fn(), + scopeConflicts: vi.fn(), + Unauthenticated, + } +}) +vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), AuditAction: {}, AuditResourceType: {} })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: mocks.Unauthenticated, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauth + checkRateLimitDirectOrThrow = mocks.rate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mocks.regime, + getUserPermissionConfigForOrganization: mocks.config, +})) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.lock })) +vi.mock('@/lib/permission-groups/repository', () => ({ + loadGroupInOrganization: mocks.group, + getGroupWorkspaces: mocks.groupWorkspaces, + getWorkspacesForGroups: mocks.workspaces, + findWorkspacesNotInOrganization: vi.fn().mockResolvedValue([]), +})) +vi.mock('@/lib/permission-groups/application/group-membership', () => ({ + findAllMembersWorkspaceConflict: mocks.conflict, + findScopeConflicts: mocks.scopeConflicts, +})) + +import { dispatchMcpOperation } from '@/lib/api/mcp/dispatch' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { + POST as internalCreate, + GET as internalList, +} from '@/app/api/organizations/[id]/permission-groups/route' +import { + DELETE, + PATCH, + GET as readGroup, +} from '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route' +import { GET, POST } from '@/app/api/v2/organizations/[organizationId]/permission-groups/route' + +const principal = { kind: 'personal_api_key', userId: 'admin-1', keyId: 'key-1' } as const +const admission = { + allowed: true, + remaining: 99, + resetAt: new Date(Date.now() + 60_000), + retryAfterMs: 0, +} +const group = { + id: 'group-1', + organizationId: 'org-1', + name: 'Restricted', + description: null, + config: DEFAULT_PERMISSION_GROUP_CONFIG, + createdBy: 'admin-1', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + isDefault: false, + membershipMode: 'inherit', + creatorName: 'Admin', + creatorEmail: null, +} +const params = { organizationId: 'org-1', groupId: 'group-1' } +const context = { params: Promise.resolve(params) } +const internalContext = { params: Promise.resolve({ id: 'org-1' }) } +const url = 'http://localhost/api/v2/organizations/org-1/permission-groups' +function request(method = 'GET', query = '', body?: unknown) { + return new NextRequest(url + query, { + method, + headers: { + 'x-api-key': 'key', + 'x-forwarded-for': '127.0.0.1', + 'content-type': 'application/json', + }, + ...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }), + }) +} +function authorize(role = 'admin') { + queueTableRows(member, [{ role }]) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['key:key-1'], + rateLimitSubscription: null, + }) + mocks.preauth.mockResolvedValue(admission) + mocks.rate.mockResolvedValue(admission) + mocks.regime.mockResolvedValue(true) + mocks.config.mockResolvedValue(null) + mocks.group.mockResolvedValue(group) + mocks.groupWorkspaces.mockResolvedValue([{ id: 'workspace-1', name: 'Engineering' }]) + mocks.workspaces.mockResolvedValue( + new Map([['group-1', [{ id: 'workspace-1', name: 'Engineering' }]]]) + ) + mocks.conflict.mockResolvedValue(null) + mocks.scopeConflicts.mockResolvedValue([]) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) +}) + +describe('permission groups across internal and public surfaces', () => { + it('dispatches the generated MCP operation through the same authorized public handler', async () => { + authorize() + const result = await dispatchMcpOperation( + { operation: 'getPermissionGroup', params }, + { + inbound: request(), + credential: { apiKey: 'key', bearer: null }, + audience: { resource: 'https://mcp.sim.test/mcp', allowUnboundApiTokens: true }, + signal: new AbortController().signal, + } + ) + expect(result.isError).not.toBe(true) + const content = result.content[0] + expect(content.type).toBe('text') + if (content.type !== 'text') throw new Error('Expected JSON tool content') + expect(JSON.parse(content.text)).toMatchObject({ + data: { id: 'group-1', organizationId: 'org-1' }, + }) + expect(mocks.group).toHaveBeenCalledWith('group-1', 'org-1', expect.anything()) + }) + + it('preserves public authorization failures over MCP', async () => { + authorize('member') + const result = await dispatchMcpOperation( + { operation: 'getPermissionGroup', params }, + { + inbound: request(), + credential: { apiKey: 'key', bearer: null }, + audience: { resource: 'https://mcp.sim.test/mcp', allowUnboundApiTokens: true }, + signal: new AbortController().signal, + } + ) + expect(result.isError).toBe(true) + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringContaining('ORGANIZATION_ADMIN_REQUIRED') }), + ]) + expect(mocks.group).not.toHaveBeenCalled() + }) + + it('authenticates before parsing a malformed public body', async () => { + mocks.authenticate.mockRejectedValue(new mocks.Unauthenticated('Invalid API key')) + const response = await POST(request('POST', '', '{'), context) + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('authenticates before parsing an internal body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + expect((await internalCreate(request('POST', '', '{'), internalContext)).status).toBe(401) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('rate limits before parsing and returns retry guidance', async () => { + mocks.rate.mockResolvedValue({ ...admission, allowed: false, remaining: 0, retryAfterMs: 2000 }) + const response = await POST(request('POST', '', '{'), context) + expect(response.status).toBe(429) + expect(response.headers.get('retry-after')).toBe('2') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('conceals unrelated organizations through the public API', async () => { + const response = await GET(request(), context) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Organization not found' }, + }) + }) + it('preserves the internal organization admin refusal', async () => { + const response = await internalList(request(), internalContext) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: 'Admin permissions required' }) + }) + it('returns a machine-readable same-organization role refusal', async () => { + authorize('member') + const response = await GET(request(), context) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { details: { code: 'ORGANIZATION_ADMIN_REQUIRED' } }, + }) + }) + it('rejects a workspace key without loading organization data', async () => { + mocks.authenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + keyType: 'workspace', + rateLimitSubjectIds: ['key:key-1'], + }) + expect((await GET(request(), context)).status).toBe(403) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('preserves internal list shape and serializes timestamps', async () => { + authorize() + queueTableRows(permissionGroup, [group]) + const response = await internalList(request(), internalContext) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + permissionGroups: [ + { + id: 'group-1', + memberCount: 0, + createdAt: group.createdAt.toISOString(), + workspaces: [{ id: 'workspace-1' }], + }, + ], + }) + }) + it('creates a group with 201 and resolved config', async () => { + authorize() + queueTableRows(permissionGroup, []) + const response = await POST( + request('POST', '', { + name: ' Restricted ', + workspaceIds: ['workspace-1'], + config: { disableCliAccess: true }, + }), + context + ) + expect(response.status).toBe(201) + expect(await response.json()).toMatchObject({ + data: { + name: 'Restricted', + organizationId: 'org-1', + membershipMode: 'inherit', + config: { disableCliAccess: true }, + workspaceIds: ['workspace-1'], + }, + }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + }) + it('rejects unknown nested policy keys before authorization', async () => { + const response = await POST( + request('POST', '', { + name: 'Restricted', + workspaceIds: ['workspace-1'], + config: { disableClAccess: true }, + }), + context + ) + expect(response.status).toBe(400) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('rejects nonexistent groups within the asserted organization', async () => { + authorize() + mocks.group.mockResolvedValue(null) + expect((await readGroup(request(), context)).status).toBe(404) + expect(mocks.group).toHaveBeenCalledWith('group-1', 'org-1', expect.anything()) + }) + it('returns conflicts without creating a group', async () => { + authorize() + mocks.conflict.mockResolvedValue({ + conflictingGroupName: 'Existing', + workspaceName: 'Engineering', + }) + const response = await POST( + request('POST', '', { name: 'Restricted', workspaceIds: ['workspace-1'] }), + context + ) + expect(response.status).toBe(409) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('patches config using the locked group and preserves omitted values', async () => { + authorize() + mocks.group.mockResolvedValue({ + ...group, + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, disableOAuthAppAccess: true }, + }) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + ...group, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableOAuthAppAccess: true, + disableCliAccess: true, + }, + }, + ]) + const response = await PATCH( + request('PATCH', '', { config: { disableCliAccess: true } }), + context + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { config: { disableOAuthAppAccess: true, disableCliAccess: true } }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ disableOAuthAppAccess: true, disableCliAccess: true }), + }) + ) + }) + it('returns 503 with Retry-After for lock contention', async () => { + authorize() + mocks.lock.mockRejectedValueOnce(Object.assign(new Error('lock timeout'), { code: '55P03' })) + const response = await DELETE(request('DELETE'), context) + expect(response.status).toBe(503) + expect(response.headers.get('retry-after')).toBeTruthy() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + it('binds cursors to organization, search and sorting but allows a different page size', async () => { + authorize() + queueTableRows(permissionGroup, [group, { ...group, id: 'group-2' }]) + const first = await GET(request('GET', '?limit=1&sortBy=name&search=Restr'), context) + expect(first.status).toBe(200) + const body = await first.json() + expect(body.data).toHaveLength(1) + expect(body.nextCursor).toEqual(expect.any(String)) + for (const query of ['?sortBy=name&search=Other', '?sortBy=name&search=Restr&sortOrder=asc']) { + expect( + ( + await GET( + request('GET', `${query}&cursor=${encodeURIComponent(body.nextCursor)}`), + context + ) + ).status + ).toBe(400) + } + expect( + ( + await GET( + request('GET', `?sortBy=name&search=Restr&cursor=${encodeURIComponent(body.nextCursor)}`), + { params: Promise.resolve({ organizationId: 'org-2' }) } + ) + ).status + ).toBe(400) + authorize() + queueTableRows(permissionGroup, []) + const last = await GET( + request( + 'GET', + `?sortBy=name&search=Restr&limit=2&cursor=${encodeURIComponent(body.nextCursor)}` + ), + context + ) + expect(last.status).toBe(200) + expect(await last.json()).toEqual({ data: [], nextCursor: null }) + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(3) + }) + it.each(['?limit=1.5', '?limit=0', '?limit=101', '?limit=', '?sortBy=bogus', '?bogus=1'])( + 'rejects unsupported query %s', + async (query) => { + expect((await GET(request('GET', query), context)).status).toBe(400) + } + ) +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.ts new file mode 100644 index 00000000000..8aae0fb26e4 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/route.ts @@ -0,0 +1,59 @@ +import { + v2CreatePermissionGroupContract, + v2ListPermissionGroupsContract, +} from '@/lib/api/contracts/v2/permission-groups' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { presentPermissionGroup } from '@/lib/api/server/permission-group-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { + createPermissionGroup, + listPermissionGroups, +} from '@/lib/permission-groups/application/use-cases' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters(params: { organizationId: string }, query: { search?: string }) { + return cursorScopeKey(cursorRoute(v2ListPermissionGroupsContract, params), { + search: query.search, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListPermissionGroupsContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), + useCase: listPermissionGroups, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data.map(presentPermissionGroup), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreatePermissionGroupContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, changes: body }), + useCase: createPermissionGroup, + present: (group) => ({ data: presentPermissionGroup(group) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts new file mode 100644 index 00000000000..c3167c9940c --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts @@ -0,0 +1,41 @@ +import { v2ListPermissionGroupWorkspacesContract } from '@/lib/api/contracts/v2/permission-groups' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { listPermissionGroupWorkspaces } from '@/lib/permission-groups/application/use-cases' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters(params: { organizationId: string }, query: { search?: string }) { + return cursorScopeKey(cursorRoute(v2ListPermissionGroupWorkspacesContract, params), { + search: query.search, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListPermissionGroupWorkspacesContract, + auth: v2ApiKeyAuth, + operation: permissionGroupOperations.listWorkspaces, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2PermissionGroupErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), + useCase: listPermissionGroupWorkspaces, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), +}) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 6d80ee68d18..60c819adfb8 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -17,7 +17,7 @@ import { export const permissionGroupFullConfigSchema = z.object(permissionGroupReadShape) export const addPermissionGroupMemberBodySchema = z.object({ - userId: z.string().min(1), + userId: z.string().min(1, 'userId is required'), }) /** Route params for organization-scoped permission-group collection routes (`id` = organizationId). */ @@ -134,22 +134,62 @@ function refineWorkspaceScope( export const createPermissionGroupBodySchema = z .object({ - name: z.string().trim().min(1).max(100), - description: z.string().trim().max(500).optional(), + name: z + .string({ error: 'name is required' }) + .trim() + .min(1, 'name is required') + .max(100, 'name cannot exceed 100 characters') + .describe('Group name, unique within the organization.'), + description: z + .string() + .trim() + .max(500, 'description cannot exceed 500 characters') + .optional() + .describe('Optional group description.'), config: permissionGroupConfigSchema.optional(), - isDefault: z.boolean().optional(), - workspaceIds: workspaceIdsSchema.optional(), + isDefault: z + .boolean() + .optional() + .describe( + 'Whether the group is the organization default. Only one group can be the default.' + ), + workspaceIds: workspaceIdsSchema + .optional() + .describe( + 'Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.' + ), }) .superRefine(refineWorkspaceScope) export type CreatePermissionGroupBody = z.input export const updatePermissionGroupBodySchema = z .object({ - name: z.string().trim().min(1).max(100).optional(), - description: z.string().trim().max(500).nullable().optional(), + name: z + .string({ error: 'name is required' }) + .trim() + .min(1, 'name is required') + .max(100, 'name cannot exceed 100 characters') + .describe('Group name, unique within the organization.') + .optional(), + description: z + .string() + .trim() + .max(500, 'description cannot exceed 500 characters') + .nullable() + .optional() + .describe('Group description. Null clears it; omission leaves it unchanged.'), config: permissionGroupConfigSchema.optional(), - isDefault: z.boolean().optional(), - workspaceIds: workspaceIdsSchema.optional(), + isDefault: z + .boolean() + .optional() + .describe( + 'Whether the group is the organization default. Only one group can be the default.' + ), + workspaceIds: workspaceIdsSchema + .optional() + .describe( + 'Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.' + ), }) .superRefine(refineWorkspaceScope) export type UpdatePermissionGroupBody = z.input @@ -195,6 +235,7 @@ export const createPermissionGroupContract = defineRouteContract({ schema: z.object({ permissionGroup: permissionGroupWriteSchema, }), + status: 201, }, }) @@ -208,6 +249,21 @@ export const getUserPermissionConfigContract = defineRouteContract({ }, }) +export const getPermissionGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/permission-groups/[groupId]', + params: permissionGroupDetailParamsSchema, + response: { + mode: 'json', + schema: z.object({ + permissionGroup: permissionGroupWriteSchema.omit({ workspaceIds: true }).extend({ + membershipMode: z.string(), + workspaces: z.array(permissionGroupWorkspaceRefSchema), + }), + }), + }, +}) + export const updatePermissionGroupContract = defineRouteContract({ method: 'PUT', path: '/api/organizations/[id]/permission-groups/[groupId]', @@ -271,6 +327,7 @@ export const addPermissionGroupMemberContract = defineRouteContract({ assignedAt: z.string(), }), }), + status: 201, }, }) 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 f8bcec04ae0..a38c118cce1 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 @@ -45,6 +45,10 @@ import { /** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */ const PAGED_LISTS = [ + 'GET /api/v2/organizations/[organizationId]/permission-groups', + 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces', + 'GET /api/v2/audit-logs', 'GET /api/v2/billing/logs', 'GET /api/v2/blocks', @@ -147,6 +151,17 @@ const FULL_SET_LISTS = [ * therefore fails here until someone decides whether the cursor is bound to it. */ const CURSOR_BINDINGS: Record = { + 'GET /api/v2/organizations/[organizationId]/permission-groups': ['search', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members': [ + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces': [ + 'search', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/audit-logs': [ 'includeDeparted', 'action', @@ -289,6 +304,13 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/organizations/[organizationId]/permission-groups': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members': [ + 'organizationId', + 'groupId', + ], + 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces': ['organizationId'], + 'GET /api/v2/files/[fileId]/versions': ['fileId'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['knowledgeBaseId'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents': [ diff --git a/apps/sim/lib/api/contracts/v2/__tests__/permission-groups.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/permission-groups.test.ts new file mode 100644 index 00000000000..4b89610622c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/permission-groups.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + v2BulkAddPermissionGroupMembersBodySchema, + v2CreatePermissionGroupBodySchema, + v2UpdatePermissionGroupBodySchema, +} from '@/lib/api/contracts/v2/permission-groups' + +describe('public permission group boundaries', () => { + it.each([ + {}, + { userIds: [] }, + { addAllOrganizationMembers: false }, + { userIds: ['user-1'], addAllOrganizationMembers: true }, + ])('rejects an ambiguous or empty bulk selection: %j', (body) => { + expect(v2BulkAddPermissionGroupMembersBodySchema.safeParse(body).success).toBe(false) + }) + + it.each([{ userIds: ['user-1'] }, { addAllOrganizationMembers: true }])( + 'accepts a bounded bulk selection: %j', + (body) => { + expect(v2BulkAddPermissionGroupMembersBodySchema.parse(body)).toEqual(body) + } + ) + + it('bounds explicit bulk membership input before any work starts', () => { + expect( + v2BulkAddPermissionGroupMembersBodySchema.safeParse({ + userIds: Array.from({ length: 1001 }, (_, index) => `user-${index}`), + }).success + ).toBe(false) + }) + + it('requires workspace scope for a new non-default group', () => { + expect(v2CreatePermissionGroupBodySchema.safeParse({ name: 'Restricted' }).success).toBe(false) + expect( + v2CreatePermissionGroupBodySchema.safeParse({ + name: 'Restricted', + workspaceIds: ['workspace-1'], + }).success + ).toBe(true) + }) + + it('rejects explicit workspace targets on a default group', () => { + expect( + v2CreatePermissionGroupBodySchema.safeParse({ + name: 'Default', + isDefault: true, + workspaceIds: ['workspace-1'], + }).success + ).toBe(false) + expect( + v2CreatePermissionGroupBodySchema.safeParse({ name: 'Default', isDefault: true }).success + ).toBe(true) + }) + + it('rejects empty patches and misspelled restriction fields', () => { + expect(v2UpdatePermissionGroupBodySchema.safeParse({}).success).toBe(false) + expect( + v2UpdatePermissionGroupBodySchema.safeParse({ config: { disableClAccess: true } }).success + ).toBe(false) + }) + + it('preserves the difference between unrestricted and empty allowlists', () => { + expect( + v2UpdatePermissionGroupBodySchema.parse({ config: { allowedIntegrations: null } }).config + ?.allowedIntegrations + ).toBeNull() + expect( + v2UpdatePermissionGroupBodySchema.parse({ config: { allowedIntegrations: [] } }).config + ?.allowedIntegrations + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts new file mode 100644 index 00000000000..e8502c2869e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts @@ -0,0 +1,395 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2AddPermissionGroupMemberContract, + v2BulkAddPermissionGroupMembersContract, + v2CreatePermissionGroupContract, + v2DeletePermissionGroupContract, + v2GetPermissionGroupContract, + v2ListPermissionGroupMembersContract, + v2ListPermissionGroupsContract, + v2ListPermissionGroupWorkspacesContract, + v2RemovePermissionGroupMemberContract, + v2UpdatePermissionGroupContract, +} from '@/lib/api/contracts/v2/permission-groups' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const TIMESTAMP = '2026-06-01T09:00:00.000Z' +const GROUP = { + id: 'group-123', + organizationId: 'org-123', + name: 'Restricted', + description: null, + config: DEFAULT_PERMISSION_GROUP_CONFIG, + isDefault: false, + membershipMode: 'inherit', + workspaceIds: ['workspace-123'], + createdBy: 'admin-123', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, +} +const MEMBER = { + id: 'assignment-123', + userId: 'user-123', + assignedAt: TIMESTAMP, + userName: 'Example Member', + userEmail: 'member@example.com', + userImage: null, +} +const AUTHORITY = `Requires organization admin or owner access and active Access Control. ${WORKSPACE_API_KEY_DENIED}` + +export const permissionGroupOpenApiRoutes = [ + defineOpenApiRoute( + v2ListPermissionGroupsContract, + { + applicationOperation: permissionGroupOperations.list, + operationId: 'listPermissionGroups', + summary: 'List Permission Groups', + description: `List permission groups in an organization with cursor pagination. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_ERRORS, + success: { description: 'List Permission Groups result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListPermissionGroupsContract.params, + 'ListPermissionGroupsParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: documentedSchema( + v2ListPermissionGroupsContract.query, + 'ListPermissionGroupsQuery', + 'Permission group list query', + 'Pagination and ordering controls.' + ), + response: documentedSchema( + v2ListPermissionGroupsContract.response.schema, + 'ListPermissionGroupsResponse', + 'List Permission Groups response', + 'List Permission Groups result.', + [{ data: [GROUP], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreatePermissionGroupContract, + { + applicationOperation: permissionGroupOperations.create, + operationId: 'createPermissionGroup', + summary: 'Create Permission Group', + description: `Create a permission group. A non-default group requires workspaces and initially governs everyone in them. Creating a default group demotes the previous default to an inactive group until it is assigned workspaces. Overlapping all-member scopes conflict. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Create Permission Group result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2CreatePermissionGroupContract.params, + 'CreatePermissionGroupParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2CreatePermissionGroupContract.query, + body: documentedSchema( + v2CreatePermissionGroupContract.body, + 'CreatePermissionGroupRequest', + 'Create Permission Group request', + 'Create Permission Group inputs.', + [{ name: 'Restricted', workspaceIds: ['workspace-123'] }] + ), + response: documentedSchema( + v2CreatePermissionGroupContract.response.schema, + 'CreatePermissionGroupResponse', + 'Create Permission Group response', + 'Create Permission Group result.', + [{ data: GROUP }] + ), + } + ), + defineOpenApiRoute( + v2GetPermissionGroupContract, + { + applicationOperation: permissionGroupOperations.read, + operationId: 'getPermissionGroup', + summary: 'Get Permission Group', + description: `Get a permission group and its resolved restrictions. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_ERRORS, + success: { description: 'Get Permission Group result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetPermissionGroupContract.params, + 'GetPermissionGroupParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2GetPermissionGroupContract.query, + response: documentedSchema( + v2GetPermissionGroupContract.response.schema, + 'GetPermissionGroupResponse', + 'Get Permission Group response', + 'Get Permission Group result.', + [{ data: GROUP }] + ), + } + ), + defineOpenApiRoute( + v2UpdatePermissionGroupContract, + { + applicationOperation: permissionGroupOperations.update, + operationId: 'updatePermissionGroup', + summary: 'Update Permission Group', + description: `Update a permission group. Omitted fields remain unchanged; config keys are patched and supplied arrays replace their lists. Promoting a group to default demotes the previous default; demoting without workspaceIds leaves it inactive. Overlapping member or all-member scopes conflict. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Update Permission Group result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdatePermissionGroupContract.params, + 'UpdatePermissionGroupParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2UpdatePermissionGroupContract.query, + body: documentedSchema( + v2UpdatePermissionGroupContract.body, + 'UpdatePermissionGroupRequest', + 'Update Permission Group request', + 'Update Permission Group inputs.', + [{ description: 'Restricted workspace access' }] + ), + response: documentedSchema( + v2UpdatePermissionGroupContract.response.schema, + 'UpdatePermissionGroupResponse', + 'Update Permission Group response', + 'Update Permission Group result.', + [{ data: GROUP }] + ), + } + ), + defineOpenApiRoute( + v2DeletePermissionGroupContract, + { + applicationOperation: permissionGroupOperations.delete, + operationId: 'deletePermissionGroup', + summary: 'Delete Permission Group', + description: `Permanently delete a permission group and its membership assignments. Members then inherit any other applicable restrictions. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_ERRORS, + success: { description: 'Delete Permission Group result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2DeletePermissionGroupContract.params, + 'DeletePermissionGroupParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2DeletePermissionGroupContract.query, + response: documentedSchema( + v2DeletePermissionGroupContract.response.schema, + 'DeletePermissionGroupResponse', + 'Delete Permission Group response', + 'Delete Permission Group result.', + [{ data: { id: 'group-123', deleted: true } }] + ), + } + ), + defineOpenApiRoute( + v2ListPermissionGroupMembersContract, + { + applicationOperation: permissionGroupOperations.listMembers, + operationId: 'listPermissionGroupMembers', + summary: 'List Permission Group Members', + description: `List explicit membership assignments in a permission group with cursor pagination. An empty inherit group applies to everyone in its workspaces. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_ERRORS, + success: { + description: 'List Permission Group Members result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListPermissionGroupMembersContract.params, + 'ListPermissionGroupMembersParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: documentedSchema( + v2ListPermissionGroupMembersContract.query, + 'ListPermissionGroupMembersQuery', + 'Permission group list query', + 'Pagination and ordering controls.' + ), + response: documentedSchema( + v2ListPermissionGroupMembersContract.response.schema, + 'ListPermissionGroupMembersResponse', + 'List Permission Group Members response', + 'List Permission Group Members result.', + [{ data: [MEMBER], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2AddPermissionGroupMemberContract, + { + applicationOperation: permissionGroupOperations.addMember, + operationId: 'addPermissionGroupMember', + summary: 'Add Permission Group Member', + description: `Assign an organization member to a permission group. An existing assignment or membership in another group targeting the same workspace returns a conflict. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Add Permission Group Member result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2AddPermissionGroupMemberContract.params, + 'AddPermissionGroupMemberParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2AddPermissionGroupMemberContract.query, + body: documentedSchema( + v2AddPermissionGroupMemberContract.body, + 'AddPermissionGroupMemberRequest', + 'Add Permission Group Member request', + 'Add Permission Group Member inputs.', + [{ userId: 'user-123' }] + ), + response: documentedSchema( + v2AddPermissionGroupMemberContract.response.schema, + 'AddPermissionGroupMemberResponse', + 'Add Permission Group Member response', + 'Add Permission Group Member result.', + [ + { + data: { + id: 'assignment-123', + permissionGroupId: 'group-123', + organizationId: 'org-123', + userId: 'user-123', + assignedBy: 'admin-123', + assignedAt: TIMESTAMP, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RemovePermissionGroupMemberContract, + { + applicationOperation: permissionGroupOperations.removeMember, + operationId: 'removePermissionGroupMember', + summary: 'Remove Permission Group Member', + description: `Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Remove Permission Group Member result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RemovePermissionGroupMemberContract.params, + 'RemovePermissionGroupMemberParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2RemovePermissionGroupMemberContract.query, + response: documentedSchema( + v2RemovePermissionGroupMemberContract.response.schema, + 'RemovePermissionGroupMemberResponse', + 'Remove Permission Group Member response', + 'Remove Permission Group Member result.', + [{ data: { id: 'assignment-123', deleted: true } }] + ), + } + ), + defineOpenApiRoute( + v2BulkAddPermissionGroupMembersContract, + { + applicationOperation: permissionGroupOperations.bulkAddMembers, + operationId: 'bulkAddPermissionGroupMembers', + summary: 'Bulk Add Permission Group Members', + description: `Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Bulk Add Permission Group Members result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2BulkAddPermissionGroupMembersContract.params, + 'BulkAddPermissionGroupMembersParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: v2BulkAddPermissionGroupMembersContract.query, + body: documentedSchema( + v2BulkAddPermissionGroupMembersContract.body, + 'BulkAddPermissionGroupMembersRequest', + 'Bulk Add Permission Group Members request', + 'Bulk Add Permission Group Members inputs.', + [{ userIds: ['user-123'] }] + ), + response: documentedSchema( + v2BulkAddPermissionGroupMembersContract.response.schema, + 'BulkAddPermissionGroupMembersResponse', + 'Bulk Add Permission Group Members response', + 'Bulk Add Permission Group Members result.', + [{ data: { added: 1, skipped: 0 } }] + ), + } + ), + defineOpenApiRoute( + v2ListPermissionGroupWorkspacesContract, + { + applicationOperation: permissionGroupOperations.listWorkspaces, + operationId: 'listPermissionGroupWorkspaces', + summary: 'List Permission Group Workspaces', + description: `List organization workspaces available for permission-group scope selection with cursor pagination. ${AUTHORITY}`, + tags: ['Permission Groups'], + errors: RESOURCE_ERRORS, + success: { + description: 'List Permission Group Workspaces result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListPermissionGroupWorkspacesContract.params, + 'ListPermissionGroupWorkspacesParams', + 'Permission group parameters', + 'Organization and permission-group identifiers.' + ), + query: documentedSchema( + v2ListPermissionGroupWorkspacesContract.query, + 'ListPermissionGroupWorkspacesQuery', + 'Permission group list query', + 'Pagination and ordering controls.' + ), + response: documentedSchema( + v2ListPermissionGroupWorkspacesContract.response.schema, + 'ListPermissionGroupWorkspacesResponse', + 'List Permission Group Workspaces response', + 'List Permission Group Workspaces result.', + [{ data: [{ id: 'workspace-123', name: 'Engineering' }], nextCursor: null }] + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 4f9a3acbdae..7c7eaf496d8 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -31,6 +31,7 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { permissionGroupOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/permission-groups' import { documentedSchema, type ErrorResponseId, @@ -2147,6 +2148,7 @@ const declaredRoutes = [ ), } ), + ...permissionGroupOpenApiRoutes, ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -2154,9 +2156,9 @@ const routes = declaredRoutes.map(withRequestBodyErrors) export const resourcesOpenApiDocument = defineOpenApiDocument({ output: 'apps/docs/openapi-v2-resources.json', info: { - title: 'Sim API v2 — Workspace Resources', + title: 'Sim API v2 — Resources', description: - 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, and the block, tool, and connector-type catalogs.', + 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, organization permission groups, and the block, tool, and connector-type catalogs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -2170,6 +2172,10 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, servers: [{ url: 'https://www.sim.ai', description: 'Production' }], tags: [ + { + name: 'Permission Groups', + description: 'Manage organization permission groups, their restrictions, and membership.', + }, { name: 'Meta', description: 'Discover what the calling API credential can reach.', diff --git a/apps/sim/lib/api/contracts/v2/permission-groups.ts b/apps/sim/lib/api/contracts/v2/permission-groups.ts new file mode 100644 index 00000000000..7ede510d75b --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/permission-groups.ts @@ -0,0 +1,336 @@ +import { z } from 'zod' +import { + createPermissionGroupBodySchema, + permissionGroupFullConfigSchema, + updatePermissionGroupBodySchema, +} from '@/lib/api/contracts/permission-groups' +import { + noInputSchema, + nonEmptyIdSchema, + organizationIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' +import { MAX_PERMISSION_GROUP_BULK_MEMBERS } from '@/lib/permission-groups/constants' +import { permissionGroupConfigSchema } from '@/lib/permission-groups/fields' + +export const v2PermissionGroupOrganizationParamsSchema = z.object({ + organizationId: organizationIdSchema.describe('Organization that owns the permission groups.'), +}) +export type V2PermissionGroupOrganizationParams = z.input< + typeof v2PermissionGroupOrganizationParamsSchema +> +export const v2PermissionGroupParamsSchema = v2PermissionGroupOrganizationParamsSchema.extend({ + groupId: nonEmptyIdSchema.describe('Permission group identifier.'), +}) +export type V2PermissionGroupParams = z.input +export const v2PermissionGroupMemberParamsSchema = v2PermissionGroupParamsSchema.extend({ + memberId: nonEmptyIdSchema.describe( + 'Membership assignment identifier returned by List Permission Group Members.' + ), +}) +export type V2PermissionGroupMemberParams = z.input + +export const v2PermissionGroupSchema = z + .object({ + id: z.string().describe('Permission group identifier.'), + organizationId: z.string().describe('Organization that owns the group.'), + name: z.string().describe('Group name, unique within the organization.'), + description: z.string().nullable().describe('Optional description of the group.'), + config: permissionGroupFullConfigSchema.describe( + 'Resolved restrictions. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none.' + ), + isDefault: z + .boolean() + .describe( + 'Whether this is the organization default, which applies across all its workspaces.' + ), + membershipMode: z + .string() + .describe( + 'An empty inherit group governs everyone in its workspaces; an empty explicit group governs nobody.' + ), + workspaceIds: z + .array(z.string()) + .describe('Workspaces governed by a non-default group. Empty for the default group.'), + createdBy: z.string().describe('User who created the group.'), + createdAt: v2TimestampSchema.describe('When the group was created.'), + updatedAt: v2TimestampSchema.describe('When the group was last updated.'), + }) + .meta({ + id: 'V2PermissionGroup', + title: 'Permission group', + description: 'An organization permission group and its resolved restrictions.', + }) +export type V2PermissionGroup = z.output + +export const v2PermissionGroupMemberSchema = z + .object({ + id: z.string().describe('Membership assignment identifier.'), + userId: z.string().describe('Organization member assigned to the group.'), + assignedAt: v2TimestampSchema.describe('When the member was assigned.'), + userName: z.string().nullable().describe('Member display name.'), + userEmail: z.string().nullable().describe('Member email address.'), + userImage: z.string().nullable().describe('Member avatar URL.'), + }) + .meta({ + id: 'V2PermissionGroupMember', + title: 'Permission group member', + description: 'An explicit permission-group membership assignment.', + }) +export type V2PermissionGroupMember = z.output + +export const v2ListPermissionGroupsQuerySchema = z + .object({ + search: v2SearchSchema.describe('Case-insensitive substring match against the group name.'), + ...v2SortFields(['name', 'createdAt', 'updatedAt'] as const, { + sortBy: 'createdAt', + sortOrder: 'desc', + }), + ...v2PaginationFields({ description: 'Maximum permission groups to return per page.' }), + }) + .strict() +export type V2ListPermissionGroupsQuery = z.output +export const v2ListPermissionGroupMembersQuerySchema = z + .object({ + ...v2SortFields(['assignedAt', 'userId'] as const, { sortBy: 'assignedAt', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum group members to return per page.' }), + }) + .strict() +export type V2ListPermissionGroupMembersQuery = z.output< + typeof v2ListPermissionGroupMembersQuerySchema +> +export const v2ListPermissionGroupWorkspacesQuerySchema = z + .object({ + search: v2SearchSchema.describe('Case-insensitive substring match against the workspace name.'), + ...v2SortFields(['name', 'id'] as const, { sortBy: 'name', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum organization workspaces to return per page.' }), + }) + .strict() +export type V2ListPermissionGroupWorkspacesQuery = z.output< + typeof v2ListPermissionGroupWorkspacesQuerySchema +> + +const configPatchSchema = permissionGroupConfigSchema + .strict() + .describe( + 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.' + ) +export const v2CreatePermissionGroupBodySchema = createPermissionGroupBodySchema + .safeExtend({ + config: configPatchSchema.optional(), + }) + .strict() + .refine((body) => body.isDefault === true || Boolean(body.workspaceIds?.length), { + path: ['workspaceIds'], + message: 'Select at least one workspace when the group targets specific workspaces', + }) +export type V2CreatePermissionGroupBody = z.input +export const v2UpdatePermissionGroupBodySchema = updatePermissionGroupBodySchema + .safeExtend({ + config: configPatchSchema.optional(), + }) + .strict() + .refine((body) => Object.values(body).some((value) => value !== undefined), { + message: 'At least one permission group field is required', + }) +export type V2UpdatePermissionGroupBody = z.input +export const v2AddPermissionGroupMemberBodySchema = z + .object({ + userId: nonEmptyIdSchema.describe('Existing organization member to add.'), + }) + .strict() +export type V2AddPermissionGroupMemberBody = z.input +export const v2BulkAddPermissionGroupMembersBodySchema = z + .object({ + userIds: z + .array(nonEmptyIdSchema) + .min(1, 'userIds cannot be empty') + .max( + MAX_PERMISSION_GROUP_BULK_MEMBERS, + 'userIds cannot exceed 1000; split the members into batches' + ) + .optional() + .describe( + 'Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.' + ), + addAllOrganizationMembers: z + .boolean() + .optional() + .describe( + 'Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.' + ), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.addAllOrganizationMembers === true ? body.userIds !== undefined : !body.userIds?.length + ) + ctx.addIssue({ + code: 'custom', + path: ['userIds'], + message: 'Provide userIds or set addAllOrganizationMembers to true, but not both', + }) + }) +export type V2BulkAddPermissionGroupMembersBody = z.input< + typeof v2BulkAddPermissionGroupMembersBodySchema +> + +export const v2ListPermissionGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups', + params: v2PermissionGroupOrganizationParamsSchema, + query: v2ListPermissionGroupsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2PermissionGroupSchema) }, +}) +export const v2CreatePermissionGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups', + params: v2PermissionGroupOrganizationParamsSchema, + query: noInputSchema, + body: v2CreatePermissionGroupBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PermissionGroupSchema), status: 201 }, +}) +export const v2GetPermissionGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + params: v2PermissionGroupParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2PermissionGroupSchema) }, +}) +export const v2UpdatePermissionGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + params: v2PermissionGroupParamsSchema, + query: noInputSchema, + body: v2UpdatePermissionGroupBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PermissionGroupSchema) }, +}) +export const v2DeletePermissionGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + params: v2PermissionGroupParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + id: z.string().describe('Deleted permission group identifier.'), + deleted: z.literal(true).describe('Whether the group was permanently deleted.'), + }) + .meta({ + id: 'V2PermissionGroupDeletion', + title: 'Permission group deletion', + description: 'Acknowledges permanent group deletion.', + }) + ), + }, +}) +export const v2ListPermissionGroupMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + params: v2PermissionGroupParamsSchema, + query: v2ListPermissionGroupMembersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2PermissionGroupMemberSchema) }, +}) +export const v2AddPermissionGroupMemberContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + params: v2PermissionGroupParamsSchema, + query: noInputSchema, + body: v2AddPermissionGroupMemberBodySchema, + response: { + mode: 'json', + status: 201, + schema: v2DataResponse( + z + .object({ + id: z.string().describe('Membership assignment identifier.'), + permissionGroupId: z.string().describe('Group receiving the member.'), + organizationId: z.string().describe('Organization that owns the group.'), + userId: z.string().describe('User assigned to the group.'), + assignedBy: z.string().describe('User who made the assignment.'), + assignedAt: v2TimestampSchema.describe('When the assignment was created.'), + }) + .meta({ + id: 'V2PermissionGroupAssignment', + title: 'Permission group assignment', + description: 'The newly created membership assignment.', + }) + ), + }, +}) +export const v2RemovePermissionGroupMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]', + params: v2PermissionGroupMemberParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + id: z.string().describe('Removed membership assignment identifier.'), + deleted: z.literal(true).describe('Whether the assignment was removed.'), + }) + .meta({ + id: 'V2PermissionGroupMemberDeletion', + title: 'Permission group member deletion', + description: 'Acknowledges membership removal.', + }) + ), + }, +}) +export const v2BulkAddPermissionGroupMembersContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk', + params: v2PermissionGroupParamsSchema, + query: noInputSchema, + body: v2BulkAddPermissionGroupMembersBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + added: z.number().describe('Number of members added.'), + skipped: z + .number() + .describe('Number of selected organization members already in the group.'), + }) + .meta({ + id: 'V2PermissionGroupBulkAdd', + title: 'Permission group bulk addition', + description: 'Counts of added and already assigned organization members.', + }) + ), + }, +}) +export const v2ListPermissionGroupWorkspacesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/workspaces', + params: v2PermissionGroupOrganizationParamsSchema, + query: v2ListPermissionGroupWorkspacesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse( + z + .object({ + id: z.string().describe('Workspace identifier.'), + name: z.string().describe('Workspace display name.'), + }) + .meta({ + id: 'V2PermissionGroupWorkspace', + title: 'Permission group workspace', + description: 'An organization workspace available for group targeting.', + }) + ), + }, +}) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 741164dd97e..20d8bdee591 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -135,6 +135,18 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + v2AddPermissionGroupMemberContract, + v2BulkAddPermissionGroupMembersContract, + v2CreatePermissionGroupContract, + v2DeletePermissionGroupContract, + v2GetPermissionGroupContract, + v2ListPermissionGroupMembersContract, + v2ListPermissionGroupsContract, + v2ListPermissionGroupWorkspacesContract, + v2RemovePermissionGroupMemberContract, + v2UpdatePermissionGroupContract, +} from '@/lib/api/contracts/v2/permission-groups' import { v2CreateSandboxContract, v2DeleteSandboxContract, @@ -317,6 +329,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + addPermissionGroupMember: { + contract: v2AddPermissionGroupMemberContract, + summary: 'Add Permission Group Member', + description: + 'Assign an organization member to a permission group. An existing assignment or membership in another group targeting the same workspace returns a conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route' + ).then((route) => route.POST), + }, addTableColumn: { contract: v2AddTableColumnContract, summary: 'Add Column', @@ -360,6 +383,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/workflows/[workflowId]/variables/route').then((route) => route.PATCH), }, + bulkAddPermissionGroupMembers: { + contract: v2BulkAddPermissionGroupMembersContract, + summary: 'Bulk Add Permission Group Members', + description: + 'Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route' + ).then((route) => route.POST), + }, bulkDeleteFiles: { contract: v2BulkDeleteFilesContract, summary: 'Delete Files', @@ -603,6 +637,17 @@ export const V2_MCP_OPERATIONS = { 'Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.POST), }, + createPermissionGroup: { + contract: v2CreatePermissionGroupContract, + summary: 'Create Permission Group', + description: + 'Create a permission group. A non-default group requires workspaces and initially governs everyone in them. Creating a default group demotes the previous default to an inactive group until it is assigned workspaces. Overlapping all-member scopes conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/route').then( + (route) => route.POST + ), + }, createSandbox: { contract: v2CreateSandboxContract, summary: 'Create Sandbox', @@ -822,6 +867,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.DELETE), }, + deletePermissionGroup: { + contract: v2DeletePermissionGroupContract, + summary: 'Delete Permission Group', + description: + 'Permanently delete a permission group and its membership assignments. Members then inherit any other applicable restrictions. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route').then( + (route) => route.DELETE + ), + }, deleteSandbox: { contract: v2DeleteSandboxContract, summary: 'Delete Sandbox', @@ -1140,6 +1196,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + getPermissionGroup: { + contract: v2GetPermissionGroupContract, + summary: 'Get Permission Group', + description: + 'Get a permission group and its resolved restrictions. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route').then( + (route) => route.GET + ), + }, getRowEnrichment: { contract: v2GetRowEnrichmentContract, summary: 'Get Enrichment Run Detail', @@ -1525,6 +1592,39 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/tools/route').then((route) => route.GET), }, + listPermissionGroupMembers: { + contract: v2ListPermissionGroupMembersContract, + summary: 'List Permission Group Members', + description: + 'List explicit membership assignments in a permission group with cursor pagination. An empty inherit group applies to everyone in its workspaces. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/route' + ).then((route) => route.GET), + }, + listPermissionGroups: { + contract: v2ListPermissionGroupsContract, + summary: 'List Permission Groups', + description: + 'List permission groups in an organization with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/route').then( + (route) => route.GET + ), + }, + listPermissionGroupWorkspaces: { + contract: v2ListPermissionGroupWorkspacesContract, + summary: 'List Permission Group Workspaces', + description: + 'List organization workspaces available for permission-group scope selection with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route').then( + (route) => route.GET + ), + }, listSandboxes: { contract: v2ListSandboxesContract, summary: 'List Sandboxes', @@ -1844,6 +1944,17 @@ export const V2_MCP_OPERATIONS = { 'Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.PATCH), }, + removePermissionGroupMember: { + contract: v2RemovePermissionGroupMemberContract, + summary: 'Remove Permission Group Member', + description: + 'Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route' + ).then((route) => route.DELETE), + }, renameFile: { contract: v2RenameFileContract, summary: 'Rename File', @@ -2168,6 +2279,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.PATCH), }, + updatePermissionGroup: { + contract: v2UpdatePermissionGroupContract, + summary: 'Update Permission Group', + description: + 'Update a permission group. Omitted fields remain unchanged; config keys are patched and supplied arrays replace their lists. Promoting a group to default demotes the previous default; demoting without workspaceIds leaves it inactive. Overlapping member or all-member scopes conflict. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/route').then( + (route) => route.PATCH + ), + }, updateRowsByFilter: { contract: v2UpdateRowsByFilterContract, summary: 'Update Rows by Filter', diff --git a/apps/sim/lib/api/server/permission-group-presenters.ts b/apps/sim/lib/api/server/permission-group-presenters.ts new file mode 100644 index 00000000000..fb461027f32 --- /dev/null +++ b/apps/sim/lib/api/server/permission-group-presenters.ts @@ -0,0 +1,12 @@ +/** Serializes domain timestamps without changing the surface's resource projection. */ +export function presentPermissionGroup(group: T) { + return { + ...group, + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), + } +} + +export function presentPermissionGroupMember(member: T) { + return { ...member, assignedAt: member.assignedAt.toISOString() } +} diff --git a/apps/sim/lib/api/server/routes/permission-groups.ts b/apps/sim/lib/api/server/routes/permission-groups.ts new file mode 100644 index 00000000000..51a7142d884 --- /dev/null +++ b/apps/sim/lib/api/server/routes/permission-groups.ts @@ -0,0 +1,30 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes/internal-json-route' +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + PermissionGroupBusyError, + PermissionGroupOrganizationNotFoundError, +} from '@/lib/permission-groups/errors' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const internalPermissionGroupErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (error instanceof PermissionGroupOrganizationNotFoundError) + return internalErrorResponse(403, { error: 'Admin permissions required' }) + if (error instanceof PermissionGroupBusyError) + return internalErrorResponse(503, { error: error.message }, { 'Retry-After': '5' }) + return null + } +) + +export const v2PermissionGroupErrorPolicy: V2ErrorPolicy = { + render(error) { + if (error instanceof PermissionGroupBusyError) + return v2Error('SERVICE_UNAVAILABLE', error.message) + return v2CaughtOrchestrationError(error) + }, +} diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index d62ef97b64e..14ae6b73196 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -39,6 +39,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/mcp-servers.mdx', 'cli/meta.mdx', 'cli/output.mdx', + 'cli/permission-groups.mdx', 'cli/profiles.mdx', 'cli/reference.mdx', 'cli/sandboxes.mdx', diff --git a/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts b/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts new file mode 100644 index 00000000000..476fecbf796 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts @@ -0,0 +1,80 @@ +import { + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { + authorizeOrganizationOperation, + type OrganizationMembershipContext, +} from '@/lib/core/application/organization-authorization' +import type { OrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + PermissionGroupOrganizationNotFoundError, + rethrowPermissionGroupWriteError, +} from '@/lib/permission-groups/errors' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' + +export interface PermissionGroupOrganizationInput { + organizationId: string +} +export interface PermissionGroupInput extends PermissionGroupOrganizationInput { + groupId: string +} + +export function defineAuthorizedPermissionGroupUseCase< + const O extends OrganizationOperation, + I extends PermissionGroupOrganizationInput, + R, +>(definition: { + operation: O + execute(args: { input: I; context: OrganizationMembershipContext }): Promise + projectAudit?(args: { + input: I + result: NoInfer + }): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] +}): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + const context = await authorizeOrganizationOperation( + principal, + definition.operation, + input + ).catch((error: unknown) => { + if (error instanceof OrchestrationError && error.code === 'not_found') + throw new PermissionGroupOrganizationNotFoundError() + if ( + error instanceof OrchestrationError && + error.code === 'forbidden' && + !(error instanceof ForbiddenOperationError) + ) + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Admin permissions required' + ) + throw error + }) + if (!(await isOrganizationPermissionRegimeActive(context.organizationId))) + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', + 'Access Control is an Enterprise feature' + ) + const result = await definition + .execute({ input, context }) + .catch(rethrowPermissionGroupWriteError) + const audit = definition.projectAudit?.({ input, result }) + if (audit) + recordProjectedUseCaseAuditEntries( + definition.operation, + null, + principal, + request, + Array.isArray(audit) ? audit : [audit], + context.organizationId + ) + return result + }, + } +} diff --git a/apps/sim/lib/permission-groups/application/operations.ts b/apps/sim/lib/permission-groups/application/operations.ts new file mode 100644 index 00000000000..fb2d7177d05 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/operations.ts @@ -0,0 +1,57 @@ +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' + +function definePermissionGroupOperation( + id: Id, + oauthScope: 'api:read' | 'api:write' +) { + return defineOrganizationOperation({ + id, + minimumRole: 'admin', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope, + capability: 'none', + }) +} + +export const permissionGroupOperations = { + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + list: definePermissionGroupOperation('permission_groups.list', 'api:read'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + read: definePermissionGroupOperation('permission_groups.read', 'api:read'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + create: definePermissionGroupOperation('permission_groups.create', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + update: definePermissionGroupOperation('permission_groups.update', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + delete: definePermissionGroupOperation('permission_groups.delete', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + listMembers: definePermissionGroupOperation('permission_groups.members.list', 'api:read'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + addMember: definePermissionGroupOperation('permission_groups.members.add', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + removeMember: definePermissionGroupOperation('permission_groups.members.remove', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + bulkAddMembers: definePermissionGroupOperation('permission_groups.members.bulk_add', 'api:write'), + /** + * permission-group-exempt: Organization admins manage Access Control itself; no group capability governs its configuration. + */ + listWorkspaces: definePermissionGroupOperation('permission_groups.workspaces.list', 'api:read'), +} as const diff --git a/apps/sim/lib/permission-groups/application/use-cases.test.ts b/apps/sim/lib/permission-groups/application/use-cases.test.ts new file mode 100644 index 00000000000..725746f68e6 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/use-cases.test.ts @@ -0,0 +1,206 @@ +/** @vitest-environment node */ +import { recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { member, permissionGroup } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { + PERMISSION_GROUP_CREATED: 'permission_group.created', + PERMISSION_GROUP_MEMBER_ADDED: 'permission_group.member_added', + }, + AuditResourceType: { PERMISSION_GROUP: 'permission_group' }, +})) + +const mocks = vi.hoisted(() => ({ + regime: vi.fn(), + config: vi.fn(), + create: vi.fn(), + bulkAdd: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mocks.regime, + getUserPermissionConfigForOrganization: mocks.config, +})) +vi.mock('@/lib/permission-groups/group-manager', () => ({ + createPermissionGroupRecord: mocks.create, + updatePermissionGroupRecord: vi.fn(), + deletePermissionGroupRecord: vi.fn(), + requirePermissionGroup: vi.fn(), +})) +vi.mock('@/lib/permission-groups/member-manager', () => ({ + bulkAddPermissionGroupMemberRecords: mocks.bulkAdd, + addPermissionGroupMemberRecord: vi.fn(), + removePermissionGroupMemberRecord: vi.fn(), +})) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { + bulkAddPermissionGroupMembers, + createPermissionGroup, + listPermissionGroups, +} from '@/lib/permission-groups/application/use-cases' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const session: Principal = { kind: 'session', userId: 'admin-1', sessionId: 'session-1' } +const key: Principal = { kind: 'personal_api_key', userId: 'admin-1', keyId: 'key-1' } +const oauth: Principal = { + kind: 'oauth_access_token', + userId: 'admin-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read', 'api:write'], + expiresAt: new Date('2099-01-01'), +} +const input = { + organizationId: 'org-1', + changes: { name: 'Restricted', workspaceIds: ['workspace-1'] }, +} +const group = { id: 'group-1', name: 'Restricted', isDefault: false, workspaceIds: ['workspace-1'] } + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.regime.mockResolvedValue(true) + mocks.config.mockResolvedValue(null) + mocks.create.mockResolvedValue(group) +}) + +describe('permission-group organization authorization', () => { + it.each([session, key, oauth])( + 'uses the real $kind actor and one semantic operation', + async (principal) => { + queueTableRows(member, [{ role: 'admin' }]) + await expect(createPermissionGroup.execute({ principal, input })).resolves.toEqual(group) + expect(mocks.create).toHaveBeenCalledExactlyOnceWith('org-1', 'admin-1', input.changes) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'admin-1', + resourceId: 'group-1', + metadata: expect.objectContaining({ + organizationId: 'org-1', + operation: 'permission_groups.create', + actor: expect.objectContaining({ kind: principal.kind }), + }), + }) + ) + } + ) + + it.each(['admin', 'owner'])('admits an organization %s', async (role) => { + queueTableRows(member, [{ role }]) + await expect(createPermissionGroup.execute({ principal: session, input })).resolves.toEqual( + group + ) + }) + + it('rejects workspace keys before protected loading', async () => { + await expect( + createPermissionGroup.execute({ + principal: { + kind: 'workspace_api_key', + keyId: 'workspace-key', + workspaceId: 'workspace-1', + }, + input, + }) + ).rejects.toMatchObject({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mocks.regime).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('conceals organizations with no current membership', async () => { + queueTableRows(member, []) + await expect(createPermissionGroup.execute({ principal: key, input })).rejects.toMatchObject({ + code: 'not_found', + message: 'Organization not found', + }) + expect(mocks.regime).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires an organization admin before checking entitlement', async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect(createPermissionGroup.execute({ principal: key, input })).rejects.toMatchObject({ + detailCode: 'ORGANIZATION_ADMIN_REQUIRED', + message: 'Admin permissions required', + }) + expect(mocks.regime).not.toHaveBeenCalled() + }) + + it('requires the active permission regime after organization authorization', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.regime.mockResolvedValue(false) + await expect( + createPermissionGroup.execute({ principal: session, input }) + ).rejects.toMatchObject({ + detailCode: 'ENTERPRISE_PLAN_REQUIRED', + message: 'Access Control is an Enterprise feature', + }) + expect(mocks.create).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it.each([ + [key, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)('rechecks credential restrictions', async (principal, restrictions) => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restrictions }) + await expect(createPermissionGroup.execute({ principal, input })).rejects.toThrow() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires write scope before loading organization membership', async () => { + await expect( + createPermissionGroup.execute({ principal: { ...oauth, scopes: ['api:read'] }, input }) + ).rejects.toThrow() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('does not translate infrastructure failures into access denials', async () => { + dbChainMockFns.select.mockImplementationOnce(() => { + throw new Error('database unavailable') + }) + await expect(createPermissionGroup.execute({ principal: session, input })).rejects.toThrow( + 'database unavailable' + ) + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('does not audit an authoritative bulk no-op', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.bulkAdd.mockResolvedValue({ group, addedUserIds: [], added: 0, skipped: 1 }) + await bulkAddPermissionGroupMembers.execute({ + principal: key, + input: { organizationId: 'org-1', groupId: 'group-1', userIds: ['member-1'] }, + }) + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('keeps every management operation on the same organization policy', () => { + for (const operation of Object.values(permissionGroupOperations)) { + expect(operation.minimumRole).toBe('admin') + expect(operation.principalKinds).toEqual([ + 'session', + 'personal_api_key', + 'oauth_access_token', + ]) + expect(Object.isFrozen(operation)).toBe(true) + } + }) + + it('lists only after current membership and entitlement pass', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(permissionGroup, []) + await expect( + listPermissionGroups.execute({ principal: key, input: { organizationId: 'org-1', limit: 1 } }) + ).resolves.toEqual({ data: [], nextCursorKeys: null }) + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(2) + }) +}) diff --git a/apps/sim/lib/permission-groups/application/use-cases.ts b/apps/sim/lib/permission-groups/application/use-cases.ts new file mode 100644 index 00000000000..d3e3aca70ed --- /dev/null +++ b/apps/sim/lib/permission-groups/application/use-cases.ts @@ -0,0 +1,200 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + defineAuthorizedPermissionGroupUseCase, + type PermissionGroupInput, + type PermissionGroupOrganizationInput, +} from '@/lib/permission-groups/application/authorized-permission-group-use-case' +import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' +import { parsePermissionGroupConfig } from '@/lib/permission-groups/fields' +import { + createPermissionGroupRecord, + deletePermissionGroupRecord, + type PermissionGroupChanges, + requirePermissionGroup, + updatePermissionGroupRecord, +} from '@/lib/permission-groups/group-manager' +import { + listPermissionGroupMemberRecords, + listPermissionGroupRecords, + listPermissionGroupWorkspaceRecords, + type PermissionGroupListOptions, + type PermissionGroupMemberSortBy, + type PermissionGroupSortBy, + type PermissionGroupWorkspaceSortBy, +} from '@/lib/permission-groups/list' +import { + addPermissionGroupMemberRecord, + type BulkAddPermissionGroupMembersInput, + bulkAddPermissionGroupMemberRecords, + removePermissionGroupMemberRecord, +} from '@/lib/permission-groups/member-manager' +import { getGroupWorkspaces } from '@/lib/permission-groups/repository' + +export const listPermissionGroups = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.list, + execute: ({ + input, + }: { + input: PermissionGroupOrganizationInput & + PermissionGroupListOptions & { search?: string } + }) => listPermissionGroupRecords(input.organizationId, input), +}) + +export const getPermissionGroup = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.read, + async execute({ input }: { input: PermissionGroupInput }) { + const group = await requirePermissionGroup(input.organizationId, input.groupId) + const workspaces = group.isDefault ? [] : await getGroupWorkspaces(group.id) + return { + ...group, + config: parsePermissionGroupConfig(group.config), + workspaces, + workspaceIds: workspaces.map((workspace) => workspace.id), + } + }, +}) + +export const createPermissionGroup = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.create, + execute: ({ + input, + context, + }: { + input: PermissionGroupOrganizationInput & { changes: PermissionGroupChanges & { name: string } } + context: { userId: string } + }) => createPermissionGroupRecord(input.organizationId, context.userId, input.changes), + projectAudit: ({ result }) => ({ + action: AuditAction.PERMISSION_GROUP_CREATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.id, + resourceName: result.name, + description: `Created permission group "${result.name}"`, + metadata: { isDefault: result.isDefault, workspaceCount: result.workspaceIds.length }, + }), +}) + +export const updatePermissionGroup = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.update, + execute: ({ input }: { input: PermissionGroupInput & { changes: PermissionGroupChanges } }) => + updatePermissionGroupRecord(input.organizationId, input.groupId, input.changes), + projectAudit: ({ input, result }) => ({ + action: AuditAction.PERMISSION_GROUP_UPDATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.id, + resourceName: result.name, + description: `Updated permission group "${result.name}"`, + metadata: { + updatedFields: Object.keys(input.changes).filter( + (key) => input.changes[key as keyof PermissionGroupChanges] !== undefined + ), + }, + }), +}) + +export const deletePermissionGroup = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.delete, + execute: ({ input }: { input: PermissionGroupInput }) => + deletePermissionGroupRecord(input.organizationId, input.groupId), + projectAudit: ({ result }) => ({ + action: AuditAction.PERMISSION_GROUP_DELETED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.id, + resourceName: result.name, + description: `Deleted permission group "${result.name}"`, + }), +}) + +export const listPermissionGroupMembers = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.listMembers, + async execute({ + input, + }: { + input: PermissionGroupInput & PermissionGroupListOptions + }) { + const group = await requirePermissionGroup(input.organizationId, input.groupId) + return listPermissionGroupMemberRecords(group.id, input) + }, +}) + +export const addPermissionGroupMember = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.addMember, + execute: ({ + input, + context, + }: { + input: PermissionGroupInput & { userId: string } + context: { userId: string } + }) => + addPermissionGroupMemberRecord( + input.organizationId, + input.groupId, + input.userId, + context.userId + ), + projectAudit: ({ result }) => ({ + action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.group.id, + resourceName: result.group.name, + description: `Added member ${result.member.userId} to permission group "${result.group.name}"`, + metadata: { targetUserId: result.member.userId, permissionGroupId: result.group.id }, + }), +}) + +export const removePermissionGroupMember = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.removeMember, + execute: ({ input }: { input: PermissionGroupInput & { memberId: string } }) => + removePermissionGroupMemberRecord(input.organizationId, input.groupId, input.memberId), + projectAudit: ({ result }) => ({ + action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.group.id, + resourceName: result.group.name, + description: `Removed member ${result.member.userId} from permission group "${result.group.name}"`, + metadata: { + targetUserId: result.member.userId, + targetEmail: result.member.email ?? undefined, + memberId: result.member.id, + permissionGroupId: result.group.id, + }, + }), +}) + +export const bulkAddPermissionGroupMembers = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.bulkAddMembers, + execute: ({ + input, + context, + }: { + input: PermissionGroupInput & BulkAddPermissionGroupMembersInput + context: { userId: string } + }) => + bulkAddPermissionGroupMemberRecords(input.organizationId, input.groupId, input, context.userId), + projectAudit: ({ result }) => + result.added + ? [ + { + action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: result.group.id, + resourceName: result.group.name, + description: `Bulk added ${result.added} member(s) to permission group "${result.group.name}"`, + metadata: { + permissionGroupId: result.group.id, + addedUserIds: result.addedUserIds, + skipped: result.skipped, + }, + }, + ] + : [], +}) + +export const listPermissionGroupWorkspaces = defineAuthorizedPermissionGroupUseCase({ + operation: permissionGroupOperations.listWorkspaces, + execute: ({ + input, + }: { + input: PermissionGroupOrganizationInput & + PermissionGroupListOptions & { search?: string } + }) => listPermissionGroupWorkspaceRecords(input.organizationId, input), +}) diff --git a/apps/sim/lib/permission-groups/constants.ts b/apps/sim/lib/permission-groups/constants.ts new file mode 100644 index 00000000000..df334aa4afa --- /dev/null +++ b/apps/sim/lib/permission-groups/constants.ts @@ -0,0 +1 @@ +export const MAX_PERMISSION_GROUP_BULK_MEMBERS = 1000 diff --git a/apps/sim/lib/permission-groups/errors.ts b/apps/sim/lib/permission-groups/errors.ts new file mode 100644 index 00000000000..f7d2e62135b --- /dev/null +++ b/apps/sim/lib/permission-groups/errors.ts @@ -0,0 +1,64 @@ +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { + AllMembersConflict, + ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' +import { + PERMISSION_GROUP_CONSTRAINTS, + PERMISSION_GROUP_MEMBER_CONSTRAINTS, +} from '@/lib/permission-groups/constraints' + +export class PermissionGroupOrganizationNotFoundError extends OrchestrationError { + constructor() { + super('not_found', 'Organization not found') + } +} + +export class PermissionGroupBusyError extends Error { + constructor() { + super('This group is being updated by another request. Please try again.') + } +} + +export function rethrowPermissionGroupWriteError(error: unknown): never { + if (getPostgresErrorCode(error) === '55P03') throw new PermissionGroupBusyError() + if (getPostgresErrorCode(error) === '23505') { + const constraint = getPostgresConstraintName(error) + if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) + throw new OrchestrationError('conflict', 'A permission group with this name already exists') + if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) + throw new OrchestrationError( + 'conflict', + 'Another group was concurrently set as the default. Please refresh and try again.' + ) + if (constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser) + throw new OrchestrationError('conflict', 'User is already in this permission group') + } + throw error +} + +/** + * Human-readable 409 message for a scope/membership conflict, naming the member + * and the group they already belong to that overlaps the requested workspaces. + */ +export function formatScopeConflictError(conflicts: ScopeConflict[]): string { + const [first] = conflicts + if (!first) { + return 'A member would be governed by two groups for the same workspace. Resolve their group memberships first.' + } + const who = first.userName || first.userEmail || 'A member' + if (conflicts.length === 1) { + return `${who} is already in the group "${first.conflictingGroupName}", which targets one of these workspaces. Remove them from one group first.` + } + const others = conflicts.length - 1 + return `${who} and ${others} other member${others === 1 ? '' : 's'} already belong to groups that target these workspaces (e.g. "${first.conflictingGroupName}"). Resolve their group memberships first.` +} + +/** + * Human-readable 409 message when another group already governs everyone in a + * workspace this group would also apply to all members of. + */ +export function formatAllMembersConflictError(conflict: AllMembersConflict): string { + return `The group "${conflict.conflictingGroupName}" already applies to everyone in "${conflict.workspaceName}". Two groups can't both govern all members of the same workspace — add members to one of them, or remove that workspace from one group first.` +} diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index bbe5445f160..88894010d1f 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -158,7 +158,7 @@ function booleanRestriction( enforcement: PermissionGroupEnforcement, feature: PlatformFeatureMeta ): BooleanRestrictionField { - const schema = z.boolean() + const schema = z.boolean().describe(feature.hint) return { kind: 'boolean-restriction', writeSchema: schema.optional(), @@ -175,7 +175,12 @@ function allowlist( enforcement: PermissionGroupEnforcement, phrasing: AllowlistPhrasing ): AllowlistField { - const schema = z.array(item).nullable() + const schema = z + .array(item) + .nullable() + .describe( + `${phrasing.limited.replace(/effectiveConfig\.\w+/g, 'this list')} Null permits every value; an empty list permits none.` + ) return { kind: 'allowlist', writeSchema: schema.optional(), @@ -192,7 +197,7 @@ function denylist( enforcement: PermissionGroupEnforcement, phrasing: string ): DenylistField { - const schema = z.array(item) + const schema = z.array(item).describe(phrasing.replace(/effectiveConfig\.\w+/g, 'this list')) return { kind: 'denylist', writeSchema: schema.optional(), diff --git a/apps/sim/lib/permission-groups/group-manager.test.ts b/apps/sim/lib/permission-groups/group-manager.test.ts new file mode 100644 index 00000000000..7840591e769 --- /dev/null +++ b/apps/sim/lib/permission-groups/group-manager.test.ts @@ -0,0 +1,157 @@ +/** @vitest-environment node */ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + lock: vi.fn(), + group: vi.fn(), + workspaces: vi.fn(), + invalidWorkspaces: vi.fn(), + allConflict: vi.fn(), + scopeConflicts: vi.fn(), +})) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.lock })) +vi.mock('@/lib/permission-groups/repository', () => ({ + loadGroupInOrganization: mocks.group, + getGroupWorkspaces: mocks.workspaces, + findWorkspacesNotInOrganization: mocks.invalidWorkspaces, +})) +vi.mock('@/lib/permission-groups/application/group-membership', () => ({ + findAllMembersWorkspaceConflict: mocks.allConflict, + findScopeConflicts: mocks.scopeConflicts, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { + createPermissionGroupRecord, + deletePermissionGroupRecord, + updatePermissionGroupRecord, +} from '@/lib/permission-groups/group-manager' + +const group = { + id: 'group-1', + organizationId: 'org-1', + name: 'Restricted', + description: null, + createdBy: 'admin-1', + createdAt: new Date(), + updatedAt: new Date(), + isDefault: false, + membershipMode: 'inherit', + config: DEFAULT_PERMISSION_GROUP_CONFIG, +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.group.mockResolvedValue(group) + mocks.workspaces.mockResolvedValue([{ id: 'workspace-1', name: 'Engineering' }]) + mocks.invalidWorkspaces.mockResolvedValue([]) + mocks.allConflict.mockResolvedValue(null) + mocks.scopeConflicts.mockResolvedValue([]) + dbChainMockFns.returning.mockResolvedValue([{ ...group }]) +}) + +describe('permission group mutation consistency', () => { + it('reads default state only after acquiring the organization lock', async () => { + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + mocks.lock.mockImplementationOnce(() => { + entered.resolve() + return released.promise + }) + const update = updatePermissionGroupRecord('org-1', 'group-1', { + workspaceIds: ['workspace-2'], + }) + await entered.promise + expect(mocks.group).not.toHaveBeenCalled() + mocks.group.mockResolvedValueOnce({ ...group, isDefault: true }) + released.resolve() + await expect(update).rejects.toMatchObject({ code: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('demotes a default to an inactive scope when no workspace list is supplied', async () => { + mocks.group.mockResolvedValueOnce({ ...group, isDefault: true }) + const result = await updatePermissionGroupRecord('org-1', 'group-1', { isDefault: false }) + expect(result.workspaceIds).toEqual([]) + expect(mocks.workspaces).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('does not classify an empty explicit group as an all-member group', async () => { + mocks.group.mockResolvedValueOnce({ ...group, membershipMode: 'explicit' }) + await updatePermissionGroupRecord('org-1', 'group-1', { workspaceIds: ['workspace-2'] }) + expect(mocks.allConflict).not.toHaveBeenCalled() + }) + + it('rejects overlapping members before changing scope', async () => { + queueTableRows(permissionGroupMember, [{ userId: 'member-1' }]) + mocks.scopeConflicts.mockResolvedValueOnce([ + { userName: 'Member', conflictingGroupName: 'Other group' }, + ]) + await expect( + updatePermissionGroupRecord('org-1', 'group-1', { workspaceIds: ['workspace-2'] }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('rejects a conflicting inherited scope without mutation', async () => { + mocks.allConflict.mockResolvedValueOnce({ + conflictingGroupName: 'Other', + workspaceName: 'Engineering', + }) + await expect( + updatePermissionGroupRecord('org-1', 'group-1', { workspaceIds: ['workspace-2'] }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('validates workspace ownership on the transaction executor', async () => { + mocks.invalidWorkspaces.mockResolvedValueOnce(['outside-workspace']) + await expect( + createPermissionGroupRecord('org-1', 'admin-1', { + name: 'Restricted', + workspaceIds: ['outside-workspace'], + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.invalidWorkspaces).toHaveBeenCalledWith(['outside-workspace'], 'org-1', db) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('rejects a duplicate name without demoting the current default', async () => { + queueTableRows(permissionGroup, [{ id: 'other-group' }]) + await expect( + createPermissionGroupRecord('org-1', 'admin-1', { name: 'Restricted', isDefault: true }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('creates the default and demotes its predecessor within one transaction', async () => { + await createPermissionGroupRecord('org-1', 'admin-1', { name: 'New default', isDefault: true }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ isDefault: false })) + expect(dbChainMockFns.insert).toHaveBeenCalledExactlyOnceWith(permissionGroup) + }) + + it('deletes only after finding the group in the asserted organization under lock', async () => { + mocks.group.mockResolvedValueOnce(null) + await expect(deletePermissionGroupRecord('org-1', 'other-group')).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.group).toHaveBeenCalledWith('other-group', 'org-1', db) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('returns the authoritative updated row without an unlocked reload', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ ...group, name: 'New name' }]) + const result = await updatePermissionGroupRecord('org-1', 'group-1', { name: 'New name' }) + expect(result.name).toBe('New name') + expect(mocks.group).toHaveBeenCalledExactlyOnceWith('group-1', 'org-1', db) + expect(mocks.workspaces).toHaveBeenCalledExactlyOnceWith('group-1', db) + }) +}) diff --git a/apps/sim/lib/permission-groups/group-manager.ts b/apps/sim/lib/permission-groups/group-manager.ts new file mode 100644 index 00000000000..fe5b43917ac --- /dev/null +++ b/apps/sim/lib/permission-groups/group-manager.ts @@ -0,0 +1,245 @@ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { + findAllMembersWorkspaceConflict, + findScopeConflicts, +} from '@/lib/permission-groups/application/group-membership' +import { + formatAllMembersConflictError, + formatScopeConflictError, +} from '@/lib/permission-groups/errors' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/fields' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { + findWorkspacesNotInOrganization, + getGroupWorkspaces, + loadGroupInOrganization, +} from '@/lib/permission-groups/repository' + +export interface PermissionGroupChanges { + name?: string + description?: string | null + config?: Partial + isDefault?: boolean + workspaceIds?: string[] +} + +export async function requirePermissionGroup( + organizationId: string, + groupId: string, + executor: DbOrTx = db +) { + const group = await loadGroupInOrganization(groupId, organizationId, executor) + if (!group) throw new OrchestrationError('not_found', 'Permission group not found') + return group +} + +async function validateWorkspaces( + organizationId: string, + workspaceIds: string[], + executor: DbOrTx +) { + if ((await findWorkspacesNotInOrganization(workspaceIds, organizationId, executor)).length) + throw new OrchestrationError( + 'validation', + 'One or more selected workspaces do not belong to this organization' + ) +} + +async function assertAvailableName( + organizationId: string, + name: string, + executor: DbOrTx, + groupId?: string +) { + const [existing] = await executor + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where(and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))) + .limit(1) + if (existing && existing.id !== groupId) + throw new OrchestrationError('conflict', 'A permission group with this name already exists') +} + +async function demoteDefault(organizationId: string, now: Date, tx: DbOrTx) { + await tx + .update(permissionGroup) + .set({ isDefault: false, updatedAt: now }) + .where( + and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true)) + ) +} + +async function insertWorkspaceLinks( + organizationId: string, + groupId: string, + workspaceIds: string[], + now: Date, + tx: DbOrTx +) { + if (workspaceIds.length) + await tx.insert(permissionGroupWorkspace).values( + workspaceIds.map((workspaceId) => ({ + id: generateId(), + permissionGroupId: groupId, + organizationId, + workspaceId, + createdAt: now, + })) + ) +} + +export async function createPermissionGroupRecord( + organizationId: string, + actorUserId: string, + input: PermissionGroupChanges & { name: string } +) { + const isDefault = input.isDefault === true + const workspaceIds = [...new Set(input.workspaceIds ?? [])] + if (isDefault && workspaceIds.length) + throw new OrchestrationError( + 'validation', + 'The default group governs all workspaces and cannot target specific workspaces' + ) + if (!isDefault && !workspaceIds.length) + throw new OrchestrationError( + 'validation', + 'Select at least one workspace when the group targets specific workspaces' + ) + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + await validateWorkspaces(organizationId, workspaceIds, tx) + await assertAvailableName(organizationId, input.name, tx) + const now = new Date() + const group = { + id: generateId(), + organizationId, + name: input.name, + description: input.description || null, + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...input.config }, + createdBy: actorUserId, + createdAt: now, + updatedAt: now, + isDefault, + membershipMode: 'inherit', + } + if (!isDefault) { + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: group.id, workspaceIds }, + tx + ) + if (conflict) + throw new OrchestrationError('conflict', formatAllMembersConflictError(conflict)) + } + if (isDefault) await demoteDefault(organizationId, now, tx) + await tx.insert(permissionGroup).values(group) + await insertWorkspaceLinks(organizationId, group.id, workspaceIds, now, tx) + return { ...group, workspaceIds } + }) +} + +export async function updatePermissionGroupRecord( + organizationId: string, + groupId: string, + updates: PermissionGroupChanges +) { + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const group = await requirePermissionGroup(organizationId, groupId, tx) + if (updates.name !== undefined) + await assertAvailableName(organizationId, updates.name, tx, groupId) + const isDefault = updates.isDefault ?? group.isDefault + if (isDefault && updates.workspaceIds !== undefined) + throw new OrchestrationError( + 'validation', + 'The default group governs all workspaces and cannot target specific workspaces' + ) + const demotingToInert = + group.isDefault && updates.isDefault === false && updates.workspaceIds === undefined + const scopeProvided = + demotingToInert || updates.workspaceIds !== undefined || updates.isDefault === true + const workspaceIds = + isDefault || demotingToInert + ? [] + : updates.workspaceIds !== undefined + ? [...new Set(updates.workspaceIds)] + : (await getGroupWorkspaces(groupId, tx)).map((workspace) => workspace.id) + if (updates.workspaceIds !== undefined) + await validateWorkspaces(organizationId, workspaceIds, tx) + if (scopeProvided) { + const members = await tx + .select({ userId: permissionGroupMember.userId }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, groupId)) + const conflicts = await findScopeConflicts( + { + organizationId, + excludeGroupId: groupId, + workspaceIds, + candidateUserIds: members.map((member) => member.userId), + }, + tx + ) + if (conflicts.length) + throw new OrchestrationError('conflict', formatScopeConflictError(conflicts)) + if (!isDefault && group.membershipMode === 'inherit' && members.length === 0) { + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: groupId, workspaceIds }, + tx + ) + if (conflict) + throw new OrchestrationError('conflict', formatAllMembersConflictError(conflict)) + } + } + const now = new Date() + if (updates.isDefault === true) await demoteDefault(organizationId, now, tx) + const config = updates.config + ? { ...parsePermissionGroupConfig(group.config), ...updates.config } + : parsePermissionGroupConfig(group.config) + const [updated] = await tx + .update(permissionGroup) + .set({ + ...(updates.name !== undefined && { name: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.isDefault !== undefined && { isDefault: updates.isDefault }), + ...(updates.config !== undefined && { config }), + updatedAt: now, + }) + .where( + and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)) + ) + .returning() + if (!updated) throw new OrchestrationError('not_found', 'Permission group not found') + if (scopeProvided) { + await tx + .delete(permissionGroupWorkspace) + .where(eq(permissionGroupWorkspace.permissionGroupId, groupId)) + await insertWorkspaceLinks(organizationId, groupId, workspaceIds, now, tx) + } + return { ...updated, config: parsePermissionGroupConfig(updated.config), workspaceIds } + }) +} + +export async function deletePermissionGroupRecord(organizationId: string, groupId: string) { + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const group = await requirePermissionGroup(organizationId, groupId, tx) + await tx + .delete(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, groupId)) + await tx + .delete(permissionGroup) + .where( + and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)) + ) + return group + }) +} diff --git a/apps/sim/lib/permission-groups/list.ts b/apps/sim/lib/permission-groups/list.ts new file mode 100644 index 00000000000..4a86e2dbc7f --- /dev/null +++ b/apps/sim/lib/permission-groups/list.ts @@ -0,0 +1,167 @@ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember, user, workspace } from '@sim/db/schema' +import { and, count, eq, inArray } from 'drizzle-orm' +import { + type CursorKey, + type KeysetKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parsePermissionGroupConfig } from '@/lib/permission-groups/fields' +import { getWorkspacesForGroups } from '@/lib/permission-groups/repository' + +export interface PermissionGroupListOptions { + sortBy?: SortBy + sortOrder?: ListSortOrder + limit?: number + cursorKeys?: CursorKey[] +} +export type PermissionGroupSortBy = 'name' | 'createdAt' | 'updatedAt' +export type PermissionGroupMemberSortBy = 'assignedAt' | 'userId' +export type PermissionGroupWorkspaceSortBy = 'name' | 'id' + +type GroupRow = typeof permissionGroup.$inferSelect +const groupSortKeys = { + name: textKey(permissionGroup.name, (row) => row.name), + createdAt: timestampKey(permissionGroup.createdAt, (row) => row.createdAt), + updatedAt: timestampKey(permissionGroup.updatedAt, (row) => row.updatedAt), +} satisfies Record> + +export async function listPermissionGroupRecords( + organizationId: string, + options: PermissionGroupListOptions & { search?: string } = {} +) { + const keys = [ + groupSortKeys[options.sortBy ?? 'createdAt'], + textKey(permissionGroup.id, (row) => row.id), + ] + const order = options.sortOrder ?? 'desc' + const query = db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + name: permissionGroup.name, + description: permissionGroup.description, + config: permissionGroup.config, + createdBy: permissionGroup.createdBy, + createdAt: permissionGroup.createdAt, + updatedAt: permissionGroup.updatedAt, + isDefault: permissionGroup.isDefault, + membershipMode: permissionGroup.membershipMode, + creatorName: user.name, + creatorEmail: user.email, + }) + .from(permissionGroup) + .leftJoin(user, eq(permissionGroup.createdBy, user.id)) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + searchFilter(permissionGroup.name, options.search), + resumeKeyset(keys, options.cursorKeys, order) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), order)) + const page = keysetPage( + keys, + await (options.limit === undefined ? query : query.limit(options.limit + 1)), + options.limit + ) + const ids = page.data.map((group) => group.id) + const counts = ids.length + ? await db + .select({ id: permissionGroupMember.permissionGroupId, count: count() }) + .from(permissionGroupMember) + .where(inArray(permissionGroupMember.permissionGroupId, ids)) + .groupBy(permissionGroupMember.permissionGroupId) + : [] + const countsById = new Map(counts.map((row) => [row.id, row.count])) + const workspaces = await getWorkspacesForGroups(ids) + return { + data: page.data.map((group) => ({ + ...group, + config: parsePermissionGroupConfig(group.config), + memberCount: countsById.get(group.id) ?? 0, + workspaces: group.isDefault ? [] : (workspaces.get(group.id) ?? []), + workspaceIds: group.isDefault ? [] : (workspaces.get(group.id) ?? []).map((item) => item.id), + })), + nextCursorKeys: page.nextCursorKeys, + } +} + +type MemberRow = { id: string; userId: string; assignedAt: Date } +const memberSortKeys = { + assignedAt: timestampKey(permissionGroupMember.assignedAt, (row) => row.assignedAt), + userId: textKey(permissionGroupMember.userId, (row) => row.userId), +} satisfies Record> + +export async function listPermissionGroupMemberRecords( + groupId: string, + options: PermissionGroupListOptions = {} +) { + const keys = [ + memberSortKeys[options.sortBy ?? 'assignedAt'], + textKey(permissionGroupMember.id, (row) => row.id), + ] + const order = options.sortOrder ?? 'asc' + const query = db + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + assignedAt: permissionGroupMember.assignedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissionGroupMember) + .leftJoin(user, eq(permissionGroupMember.userId, user.id)) + .where( + and( + eq(permissionGroupMember.permissionGroupId, groupId), + resumeKeyset(keys, options.cursorKeys, order) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), order)) + return keysetPage( + keys, + await (options.limit === undefined ? query : query.limit(options.limit + 1)), + options.limit + ) +} + +type WorkspaceRow = { id: string; name: string } +const workspaceSortKeys = { + name: textKey(workspace.name, (row) => row.name), + id: textKey(workspace.id, (row) => row.id), +} satisfies Record> + +export async function listPermissionGroupWorkspaceRecords( + organizationId: string, + options: PermissionGroupListOptions & { search?: string } = {} +) { + const sortBy = options.sortBy ?? 'name' + const keys = + sortBy === 'id' ? [workspaceSortKeys.id] : [workspaceSortKeys.name, workspaceSortKeys.id] + const order = options.sortOrder ?? 'asc' + const query = db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where( + and( + eq(workspace.organizationId, organizationId), + searchFilter(workspace.name, options.search), + resumeKeyset(keys, options.cursorKeys, order) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), order)) + return keysetPage( + keys, + await (options.limit === undefined ? query : query.limit(options.limit + 1)), + options.limit + ) +} diff --git a/apps/sim/lib/permission-groups/member-manager.test.ts b/apps/sim/lib/permission-groups/member-manager.test.ts new file mode 100644 index 00000000000..bef4027652e --- /dev/null +++ b/apps/sim/lib/permission-groups/member-manager.test.ts @@ -0,0 +1,137 @@ +/** @vitest-environment node */ +import { member, permissionGroupMember } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + group: vi.fn(), + workspaces: vi.fn(), + allConflict: vi.fn(), + scopeConflicts: vi.fn(), +})) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: vi.fn() })) +vi.mock('@/lib/permission-groups/group-manager', () => ({ requirePermissionGroup: mocks.group })) +vi.mock('@/lib/permission-groups/repository', () => ({ getGroupWorkspaces: mocks.workspaces })) +vi.mock('@/lib/permission-groups/application/group-membership', () => ({ + findAllMembersWorkspaceConflict: mocks.allConflict, + findScopeConflicts: mocks.scopeConflicts, +})) + +import { + addPermissionGroupMemberRecord, + bulkAddPermissionGroupMemberRecords, + removePermissionGroupMemberRecord, +} from '@/lib/permission-groups/member-manager' + +const group = { id: 'group-1', name: 'Restricted', isDefault: false, membershipMode: 'inherit' } +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.group.mockResolvedValue(group) + mocks.workspaces.mockResolvedValue([{ id: 'workspace-1' }]) + mocks.allConflict.mockResolvedValue(null) + mocks.scopeConflicts.mockResolvedValue([]) +}) + +describe('permission-group membership mutations', () => { + it('requires organization membership for a single addition', async () => { + await expect( + addPermissionGroupMemberRecord('org-1', 'group-1', 'outsider', 'admin-1') + ).rejects.toMatchObject({ code: 'validation' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('refuses duplicate assignments', async () => { + queueTableRows(member, [{ id: 'org-member-1' }]) + queueTableRows(permissionGroupMember, [{ id: 'assignment-1' }]) + await expect( + addPermissionGroupMemberRecord('org-1', 'group-1', 'member-1', 'admin-1') + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('checks overlap before writing a single assignment', async () => { + queueTableRows(member, [{ id: 'org-member-1' }]) + mocks.scopeConflicts.mockResolvedValue([{ userName: 'Member', conflictingGroupName: 'Other' }]) + await expect( + addPermissionGroupMemberRecord('org-1', 'group-1', 'member-1', 'admin-1') + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('records the actual assigning user', async () => { + queueTableRows(member, [{ id: 'org-member-1' }]) + const result = await addPermissionGroupMemberRecord('org-1', 'group-1', 'member-1', 'admin-1') + expect(result.member).toMatchObject({ + userId: 'member-1', + assignedBy: 'admin-1', + organizationId: 'org-1', + permissionGroupId: 'group-1', + }) + }) + it('rejects the entire bulk selection on a membership overlap', async () => { + queueTableRows(member, [{ userId: 'member-1' }, { userId: 'member-2' }]) + mocks.scopeConflicts.mockResolvedValue([{ userName: 'Member', conflictingGroupName: 'Other' }]) + await expect( + bulkAddPermissionGroupMemberRecords( + 'org-1', + 'group-1', + { userIds: ['member-1', 'member-2'] }, + 'admin-1' + ) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('deduplicates, ignores outsiders and skips current assignments', async () => { + queueTableRows(member, [{ userId: 'member-1' }, { userId: 'member-2' }]) + queueTableRows(permissionGroupMember, [{ userId: 'member-1' }]) + const result = await bulkAddPermissionGroupMemberRecords( + 'org-1', + 'group-1', + { userIds: ['member-1', 'member-2', 'member-2', 'outsider'] }, + 'admin-1' + ) + expect(result).toMatchObject({ added: 1, skipped: 1, addedUserIds: ['member-2'] }) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ userId: 'member-2', assignedBy: 'admin-1' }), + ]) + }) + it('bounds an add-all expansion before materializing writes', async () => { + queueTableRows( + member, + Array.from({ length: 1001 }, (_, index) => ({ userId: `member-${index}` })) + ) + await expect( + bulkAddPermissionGroupMemberRecords( + 'org-1', + 'group-1', + { addAllOrganizationMembers: true }, + 'admin-1' + ) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(1001) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('will not expand the last-member removal into an overlapping all-member scope', async () => { + queueTableRows(permissionGroupMember, [{ id: 'assignment-1', userId: 'member-1', email: null }]) + queueTableRows(permissionGroupMember, [{ value: 1 }]) + mocks.allConflict.mockResolvedValue({ + conflictingGroupName: 'Other', + workspaceName: 'Engineering', + }) + await expect( + removePermissionGroupMemberRecord('org-1', 'group-1', 'assignment-1') + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + it('can empty a group in explicit membership mode', async () => { + mocks.group.mockResolvedValue({ ...group, membershipMode: 'explicit' }) + queueTableRows(permissionGroupMember, [{ id: 'assignment-1', userId: 'member-1', email: null }]) + await removePermissionGroupMemberRecord('org-1', 'group-1', 'assignment-1') + expect(mocks.allConflict).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + }) + it('does not delete an assignment absent from the requested group', async () => { + await expect( + removePermissionGroupMemberRecord('org-1', 'group-1', 'other-assignment') + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-groups/member-manager.ts b/apps/sim/lib/permission-groups/member-manager.ts new file mode 100644 index 00000000000..db05d4de99e --- /dev/null +++ b/apps/sim/lib/permission-groups/member-manager.ts @@ -0,0 +1,195 @@ +import { db } from '@sim/db' +import { member, permissionGroupMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, count, eq, inArray } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + findAllMembersWorkspaceConflict, + findScopeConflicts, +} from '@/lib/permission-groups/application/group-membership' +import { MAX_PERMISSION_GROUP_BULK_MEMBERS } from '@/lib/permission-groups/constants' +import { + formatAllMembersConflictError, + formatScopeConflictError, +} from '@/lib/permission-groups/errors' +import { requirePermissionGroup } from '@/lib/permission-groups/group-manager' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { getGroupWorkspaces } from '@/lib/permission-groups/repository' + +export async function addPermissionGroupMemberRecord( + organizationId: string, + groupId: string, + userId: string, + actorUserId: string +) { + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const group = await requirePermissionGroup(organizationId, groupId, tx) + const [organizationMember] = await tx + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1) + if (!organizationMember) + throw new OrchestrationError('validation', 'User is not a member of this organization') + const [existing] = await tx + .select({ id: permissionGroupMember.id }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.permissionGroupId, groupId), + eq(permissionGroupMember.userId, userId) + ) + ) + .limit(1) + if (existing) + throw new OrchestrationError('conflict', 'User is already in this permission group') + const workspaceIds = (await getGroupWorkspaces(groupId, tx)).map((workspace) => workspace.id) + const conflicts = await findScopeConflicts( + { organizationId, excludeGroupId: groupId, workspaceIds, candidateUserIds: [userId] }, + tx + ) + if (conflicts.length) + throw new OrchestrationError('conflict', formatScopeConflictError(conflicts)) + const assignment = { + id: generateId(), + permissionGroupId: groupId, + organizationId, + userId, + assignedBy: actorUserId, + assignedAt: new Date(), + } + await tx.insert(permissionGroupMember).values(assignment) + return { group, member: assignment } + }) +} + +export async function removePermissionGroupMemberRecord( + organizationId: string, + groupId: string, + memberId: string +) { + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const group = await requirePermissionGroup(organizationId, groupId, tx) + const [assignment] = await tx + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + email: user.email, + }) + .from(permissionGroupMember) + .innerJoin(user, eq(permissionGroupMember.userId, user.id)) + .where( + and( + eq(permissionGroupMember.id, memberId), + eq(permissionGroupMember.permissionGroupId, groupId) + ) + ) + .limit(1) + if (!assignment) throw new OrchestrationError('not_found', 'Member not found') + if (!group.isDefault && group.membershipMode === 'inherit') { + const [members] = await tx + .select({ value: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, groupId)) + if ((members?.value ?? 0) <= 1) { + const workspaceIds = (await getGroupWorkspaces(groupId, tx)).map( + (workspace) => workspace.id + ) + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: groupId, workspaceIds }, + tx + ) + if (conflict) + throw new OrchestrationError('conflict', formatAllMembersConflictError(conflict)) + } + } + await tx + .delete(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.id, memberId), + eq(permissionGroupMember.permissionGroupId, groupId) + ) + ) + return { group, member: assignment } + }) +} + +export interface BulkAddPermissionGroupMembersInput { + userIds?: string[] + addAllOrganizationMembers?: boolean +} + +export async function bulkAddPermissionGroupMemberRecords( + organizationId: string, + groupId: string, + input: BulkAddPermissionGroupMembersInput, + actorUserId: string +) { + const uniqueUserIds = [...new Set(input.userIds ?? [])] + if (!input.addAllOrganizationMembers && uniqueUserIds.length > MAX_PERMISSION_GROUP_BULK_MEMBERS) + throw new OrchestrationError( + 'validation', + 'userIds cannot exceed 1000; split the members into batches' + ) + return db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const group = await requirePermissionGroup(organizationId, groupId, tx) + if (!input.addAllOrganizationMembers && !uniqueUserIds.length) + return { group, addedUserIds: [] as string[], added: 0, skipped: 0 } + const candidates = await tx + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + input.addAllOrganizationMembers ? undefined : inArray(member.userId, uniqueUserIds) + ) + ) + .limit(MAX_PERMISSION_GROUP_BULK_MEMBERS + 1) + if (candidates.length > MAX_PERMISSION_GROUP_BULK_MEMBERS) + throw new OrchestrationError( + 'payload_too_large', + 'The organization has more than 1000 members; add members in batches using userIds' + ) + const targetUserIds = [...new Set(candidates.map((candidate) => candidate.userId))] + if (!targetUserIds.length) return { group, addedUserIds: [] as string[], added: 0, skipped: 0 } + const workspaceIds = (await getGroupWorkspaces(groupId, tx)).map((workspace) => workspace.id) + const conflicts = await findScopeConflicts( + { organizationId, excludeGroupId: groupId, workspaceIds, candidateUserIds: targetUserIds }, + tx + ) + if (conflicts.length) + throw new OrchestrationError('conflict', formatScopeConflictError(conflicts)) + const existing = await tx + .select({ userId: permissionGroupMember.userId }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.permissionGroupId, groupId), + inArray(permissionGroupMember.userId, targetUserIds) + ) + ) + const existingIds = new Set(existing.map((assignment) => assignment.userId)) + const addedUserIds = targetUserIds.filter((userId) => !existingIds.has(userId)) + if (addedUserIds.length) + await tx.insert(permissionGroupMember).values( + addedUserIds.map((userId) => ({ + id: generateId(), + permissionGroupId: groupId, + organizationId, + userId, + assignedBy: actorUserId, + assignedAt: new Date(), + })) + ) + return { + group, + addedUserIds, + added: addedUserIds.length, + skipped: targetUserIds.length - addedUserIds.length, + } + }) +} diff --git a/apps/sim/lib/permission-groups/repository.ts b/apps/sim/lib/permission-groups/repository.ts new file mode 100644 index 00000000000..f87b8d8be6c --- /dev/null +++ b/apps/sim/lib/permission-groups/repository.ts @@ -0,0 +1,90 @@ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema' +import { and, asc, eq, inArray } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +/** A workspace reference (id + display name). */ +export interface WorkspaceRef { + id: string + name: string +} + +/** Load a permission group only if it belongs to the given organization. */ +export async function loadGroupInOrganization( + groupId: string, + organizationId: string, + executor: DbOrTx = db +) { + const [group] = await executor + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + name: permissionGroup.name, + description: permissionGroup.description, + config: permissionGroup.config, + createdBy: permissionGroup.createdBy, + createdAt: permissionGroup.createdAt, + updatedAt: permissionGroup.updatedAt, + isDefault: permissionGroup.isDefault, + membershipMode: permissionGroup.membershipMode, + }) + .from(permissionGroup) + .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) + .limit(1) + + return group ?? null +} + +/** The workspaces ({id, name}) a specific-scope group targets. */ +export async function getGroupWorkspaces( + groupId: string, + executor: DbOrTx = db +): Promise { + return executor + .select({ id: workspace.id, name: workspace.name }) + .from(permissionGroupWorkspace) + .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) + .where(eq(permissionGroupWorkspace.permissionGroupId, groupId)) + .orderBy(asc(workspace.name)) +} + +/** Batched map of `groupId -> targeted workspaces` for a list of groups. */ +export async function getWorkspacesForGroups( + groupIds: string[] +): Promise> { + const byGroup = new Map() + if (groupIds.length === 0) return byGroup + + const rows = await db + .select({ + groupId: permissionGroupWorkspace.permissionGroupId, + id: workspace.id, + name: workspace.name, + }) + .from(permissionGroupWorkspace) + .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) + .where(inArray(permissionGroupWorkspace.permissionGroupId, groupIds)) + .orderBy(asc(workspace.name)) + + for (const row of rows) { + const list = byGroup.get(row.groupId) ?? [] + list.push({ id: row.id, name: row.name }) + byGroup.set(row.groupId, list) + } + return byGroup +} + +/** Returns the subset of `workspaceIds` that do NOT belong to the organization. */ +export async function findWorkspacesNotInOrganization( + workspaceIds: string[], + organizationId: string, + executor: DbOrTx = db +): Promise { + if (workspaceIds.length === 0) return [] + const rows = await executor + .select({ id: workspace.id }) + .from(workspace) + .where(and(inArray(workspace.id, workspaceIds), eq(workspace.organizationId, organizationId))) + const valid = new Set(rows.map((row) => row.id)) + return workspaceIds.filter((id) => !valid.has(id)) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c3cde4daf57..876319aa0e2 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,5 +1,13 @@ import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' +const PERMISSION_GROUP_ORGANIZATION_FLAG = { + organizationId: { name: 'organization', describe: 'Organization that owns the permission group' }, +} as const +const PERMISSION_GROUP_MEMBER_FLAGS = { + ...PERMISSION_GROUP_ORGANIZATION_FLAG, + groupId: { name: 'group', describe: 'Permission group identifier' }, +} as const + const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' @@ -934,6 +942,62 @@ export const CLI_CONTRACT: CliContract = { { header: 'built-in', path: 'readOnly', format: 'bool' }, ], }, + listPermissionGroups: { + command: 'permission-groups list', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'default', path: 'isDefault', format: 'bool' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + getPermissionGroup: { + command: 'permission-groups get', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + }, + createPermissionGroup: { + command: 'permission-groups create', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + }, + updatePermissionGroup: { + command: 'permission-groups update', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + }, + deletePermissionGroup: { + command: 'permission-groups delete', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + confirm: 'This permanently deletes the permission group and its member assignments.', + }, + listPermissionGroupMembers: { + command: 'permission-groups members list', + pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + columns: [ + { header: 'id' }, + { header: 'user', path: 'userId' }, + { header: 'name', path: 'userName' }, + { header: 'email', path: 'userEmail' }, + ], + }, + addPermissionGroupMember: { + command: 'permission-groups members add', + pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + }, + removePermissionGroupMember: { + command: 'permission-groups members remove', + pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + confirm: + 'This removes the member assignment and changes which permission groups apply to the user.', + }, + bulkAddPermissionGroupMembers: { + command: 'permission-groups members batch-add', + pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + }, + listPermissionGroupWorkspaces: { + command: 'permission-groups workspaces', + pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + columns: [{ header: 'id' }, { header: 'name' }], + }, listCustomTools: { columns: [ { header: 'id' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index dc8616b41b2..f1eb6359b13 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -169,6 +169,31 @@ export type ActivateWorkflowVersionResponse = { data: ActivateWorkflowVersionResponseRef4 } +/** `POST /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members` */ +export type AddPermissionGroupMemberParams = { + organizationId: string + groupId: string +} + +export type AddPermissionGroupMemberQuery = Record + +export type AddPermissionGroupMemberBody = { + userId: string +} + +type AddPermissionGroupMemberResponseRef0 = { + id: string + permissionGroupId: string + organizationId: string + userId: string + assignedBy: string + assignedAt: string +} + +export type AddPermissionGroupMemberResponse = { + data: AddPermissionGroupMemberResponseRef0 +} + /** `POST /api/v2/tables/[tableId]/columns` */ export type AddTableColumnParams = { tableId: string @@ -614,6 +639,28 @@ export type ApplyWorkflowVariablesResponse = { data: ApplyWorkflowVariablesResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk` */ +export type BulkAddPermissionGroupMembersParams = { + organizationId: string + groupId: string +} + +export type BulkAddPermissionGroupMembersQuery = Record + +export type BulkAddPermissionGroupMembersBody = { + userIds?: Array + addAllOrganizationMembers?: boolean +} + +type BulkAddPermissionGroupMembersResponseRef0 = { + added: number + skipped: number +} + +export type BulkAddPermissionGroupMembersResponse = { + data: BulkAddPermissionGroupMembersResponseRef0 +} + /** `POST /api/v2/files/bulk-delete` */ export type BulkDeleteFilesQuery = Record @@ -1911,6 +1958,125 @@ export type CreateMcpServerResponse = { data: CreateMcpServerResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/permission-groups` */ +export type CreatePermissionGroupParams = { + organizationId: string +} + +export type CreatePermissionGroupQuery = Record + +export type CreatePermissionGroupBody = { + name: string + description?: string + config?: { + allowedIntegrations?: Array | null + allowedModelProviders?: Array | null + deniedModels?: Array + deniedTools?: Array + hideTraceSpans?: boolean + hideKnowledgeBaseTab?: boolean + hideTablesTab?: boolean + hideCopilot?: boolean + hideIntegrationsTab?: boolean + hideSecretsTab?: boolean + hideApiKeysTab?: boolean + hideInboxTab?: boolean + hideFilesTab?: boolean + disableMcpTools?: boolean + disableCustomTools?: boolean + disableSkills?: boolean + disableInvitations?: boolean + disablePublicApi?: boolean + disablePublicFileSharing?: boolean + allowedFileShareAuthTypes?: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi?: boolean + hideDeployMcp?: boolean + hideDeployChatbot?: boolean + allowedChatDeployAuthTypes?: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys?: boolean + disableLogExport?: boolean + hideCostInfo?: boolean + disableKnowledgeBaseCreation?: boolean + disableKnowledgeBaseFileUpload?: boolean + allowedKnowledgeConnectors?: Array | null + disableTableCreation?: boolean + disableTableExport?: boolean + disableBulkFileDownload?: boolean + disablePersonalCredentials?: boolean + disableWorkspaceCreation?: boolean + hideOrgMemberDirectory?: boolean + disableCliAccess?: boolean + disableWebhookTriggers?: boolean + disableToolAutoApproval?: boolean + hideSandboxesTab?: boolean + disableOAuthAppAccess?: boolean + disableKnowledgeBaseExport?: boolean + } + isDefault?: boolean + workspaceIds?: Array +} + +type CreatePermissionGroupResponseRef0 = { + id: string + organizationId: string + name: string + description: string | null + config: { + allowedIntegrations: Array | null + allowedModelProviders: Array | null + deniedModels: Array + deniedTools: Array + hideTraceSpans: boolean + hideKnowledgeBaseTab: boolean + hideTablesTab: boolean + hideCopilot: boolean + hideIntegrationsTab: boolean + hideSecretsTab: boolean + hideApiKeysTab: boolean + hideInboxTab: boolean + hideFilesTab: boolean + disableMcpTools: boolean + disableCustomTools: boolean + disableSkills: boolean + disableInvitations: boolean + disablePublicApi: boolean + disablePublicFileSharing: boolean + allowedFileShareAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi: boolean + hideDeployMcp: boolean + hideDeployChatbot: boolean + allowedChatDeployAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys: boolean + disableLogExport: boolean + hideCostInfo: boolean + disableKnowledgeBaseCreation: boolean + disableKnowledgeBaseFileUpload: boolean + allowedKnowledgeConnectors: Array | null + disableTableCreation: boolean + disableTableExport: boolean + disableBulkFileDownload: boolean + disablePersonalCredentials: boolean + disableWorkspaceCreation: boolean + hideOrgMemberDirectory: boolean + disableCliAccess: boolean + disableWebhookTriggers: boolean + disableToolAutoApproval: boolean + hideSandboxesTab: boolean + disableOAuthAppAccess: boolean + disableKnowledgeBaseExport: boolean + } + isDefault: boolean + membershipMode: string + workspaceIds: Array + createdBy: string + createdAt: string + updatedAt: string +} + +export type CreatePermissionGroupResponse = { + data: CreatePermissionGroupResponseRef0 +} + /** `POST /api/v2/sandboxes` */ export type CreateSandboxQuery = Record @@ -2992,6 +3158,23 @@ export type DeleteMcpServerResponse = { data: DeleteMcpServerResponseRef0 } +/** `DELETE /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ +export type DeletePermissionGroupParams = { + organizationId: string + groupId: string +} + +export type DeletePermissionGroupQuery = Record + +type DeletePermissionGroupResponseRef0 = { + id: string + deleted: true +} + +export type DeletePermissionGroupResponse = { + data: DeletePermissionGroupResponseRef0 +} + /** `DELETE /api/v2/sandboxes/[sandboxId]` */ export type DeleteSandboxParams = { sandboxId: string @@ -4658,6 +4841,75 @@ export type GetNextKnowledgeTagSlotResponse = { data: GetNextKnowledgeTagSlotResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ +export type GetPermissionGroupParams = { + organizationId: string + groupId: string +} + +export type GetPermissionGroupQuery = Record + +type GetPermissionGroupResponseRef0 = { + id: string + organizationId: string + name: string + description: string | null + config: { + allowedIntegrations: Array | null + allowedModelProviders: Array | null + deniedModels: Array + deniedTools: Array + hideTraceSpans: boolean + hideKnowledgeBaseTab: boolean + hideTablesTab: boolean + hideCopilot: boolean + hideIntegrationsTab: boolean + hideSecretsTab: boolean + hideApiKeysTab: boolean + hideInboxTab: boolean + hideFilesTab: boolean + disableMcpTools: boolean + disableCustomTools: boolean + disableSkills: boolean + disableInvitations: boolean + disablePublicApi: boolean + disablePublicFileSharing: boolean + allowedFileShareAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi: boolean + hideDeployMcp: boolean + hideDeployChatbot: boolean + allowedChatDeployAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys: boolean + disableLogExport: boolean + hideCostInfo: boolean + disableKnowledgeBaseCreation: boolean + disableKnowledgeBaseFileUpload: boolean + allowedKnowledgeConnectors: Array | null + disableTableCreation: boolean + disableTableExport: boolean + disableBulkFileDownload: boolean + disablePersonalCredentials: boolean + disableWorkspaceCreation: boolean + hideOrgMemberDirectory: boolean + disableCliAccess: boolean + disableWebhookTriggers: boolean + disableToolAutoApproval: boolean + hideSandboxesTab: boolean + disableOAuthAppAccess: boolean + disableKnowledgeBaseExport: boolean + } + isDefault: boolean + membershipMode: string + workspaceIds: Array + createdBy: string + createdAt: string + updatedAt: string +} + +export type GetPermissionGroupResponse = { + data: GetPermissionGroupResponseRef0 +} + /** `GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ export type GetRowEnrichmentParams = { tableId: string @@ -6759,6 +7011,131 @@ export type ListMcpServerToolsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members` */ +export type ListPermissionGroupMembersParams = { + organizationId: string + groupId: string +} + +export type ListPermissionGroupMembersQuery = { + sortBy?: 'assignedAt' | 'userId' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListPermissionGroupMembersResponseRef0 = { + id: string + userId: string + assignedAt: string + userName: string | null + userEmail: string | null + userImage: string | null +} + +export type ListPermissionGroupMembersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations/[organizationId]/permission-groups` */ +export type ListPermissionGroupsParams = { + organizationId: string +} + +export type ListPermissionGroupsQuery = { + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListPermissionGroupsResponseRef0 = { + id: string + organizationId: string + name: string + description: string | null + config: { + allowedIntegrations: Array | null + allowedModelProviders: Array | null + deniedModels: Array + deniedTools: Array + hideTraceSpans: boolean + hideKnowledgeBaseTab: boolean + hideTablesTab: boolean + hideCopilot: boolean + hideIntegrationsTab: boolean + hideSecretsTab: boolean + hideApiKeysTab: boolean + hideInboxTab: boolean + hideFilesTab: boolean + disableMcpTools: boolean + disableCustomTools: boolean + disableSkills: boolean + disableInvitations: boolean + disablePublicApi: boolean + disablePublicFileSharing: boolean + allowedFileShareAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi: boolean + hideDeployMcp: boolean + hideDeployChatbot: boolean + allowedChatDeployAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys: boolean + disableLogExport: boolean + hideCostInfo: boolean + disableKnowledgeBaseCreation: boolean + disableKnowledgeBaseFileUpload: boolean + allowedKnowledgeConnectors: Array | null + disableTableCreation: boolean + disableTableExport: boolean + disableBulkFileDownload: boolean + disablePersonalCredentials: boolean + disableWorkspaceCreation: boolean + hideOrgMemberDirectory: boolean + disableCliAccess: boolean + disableWebhookTriggers: boolean + disableToolAutoApproval: boolean + hideSandboxesTab: boolean + disableOAuthAppAccess: boolean + disableKnowledgeBaseExport: boolean + } + isDefault: boolean + membershipMode: string + workspaceIds: Array + createdBy: string + createdAt: string + updatedAt: string +} + +export type ListPermissionGroupsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations/[organizationId]/permission-groups/workspaces` */ +export type ListPermissionGroupWorkspacesParams = { + organizationId: string +} + +export type ListPermissionGroupWorkspacesQuery = { + search?: string + sortBy?: 'name' | 'id' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListPermissionGroupWorkspacesResponseRef0 = { + id: string + name: string +} + +export type ListPermissionGroupWorkspacesResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/sandboxes` */ export type ListSandboxesQuery = { workspaceId: string @@ -8760,6 +9137,24 @@ export type RelocateWorkflowFolderResponse = { data: RelocateWorkflowFolderResponseRef0 } +/** `DELETE /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]` */ +export type RemovePermissionGroupMemberParams = { + organizationId: string + groupId: string + memberId: string +} + +export type RemovePermissionGroupMemberQuery = Record + +type RemovePermissionGroupMemberResponseRef0 = { + id: string + deleted: true +} + +export type RemovePermissionGroupMemberResponse = { + data: RemovePermissionGroupMemberResponseRef0 +} + /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -10303,6 +10698,126 @@ export type UpdateMcpServerResponse = { data: UpdateMcpServerResponseRef0 } +/** `PATCH /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ +export type UpdatePermissionGroupParams = { + organizationId: string + groupId: string +} + +export type UpdatePermissionGroupQuery = Record + +export type UpdatePermissionGroupBody = { + name?: string + description?: string | null + config?: { + allowedIntegrations?: Array | null + allowedModelProviders?: Array | null + deniedModels?: Array + deniedTools?: Array + hideTraceSpans?: boolean + hideKnowledgeBaseTab?: boolean + hideTablesTab?: boolean + hideCopilot?: boolean + hideIntegrationsTab?: boolean + hideSecretsTab?: boolean + hideApiKeysTab?: boolean + hideInboxTab?: boolean + hideFilesTab?: boolean + disableMcpTools?: boolean + disableCustomTools?: boolean + disableSkills?: boolean + disableInvitations?: boolean + disablePublicApi?: boolean + disablePublicFileSharing?: boolean + allowedFileShareAuthTypes?: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi?: boolean + hideDeployMcp?: boolean + hideDeployChatbot?: boolean + allowedChatDeployAuthTypes?: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys?: boolean + disableLogExport?: boolean + hideCostInfo?: boolean + disableKnowledgeBaseCreation?: boolean + disableKnowledgeBaseFileUpload?: boolean + allowedKnowledgeConnectors?: Array | null + disableTableCreation?: boolean + disableTableExport?: boolean + disableBulkFileDownload?: boolean + disablePersonalCredentials?: boolean + disableWorkspaceCreation?: boolean + hideOrgMemberDirectory?: boolean + disableCliAccess?: boolean + disableWebhookTriggers?: boolean + disableToolAutoApproval?: boolean + hideSandboxesTab?: boolean + disableOAuthAppAccess?: boolean + disableKnowledgeBaseExport?: boolean + } + isDefault?: boolean + workspaceIds?: Array +} + +type UpdatePermissionGroupResponseRef0 = { + id: string + organizationId: string + name: string + description: string | null + config: { + allowedIntegrations: Array | null + allowedModelProviders: Array | null + deniedModels: Array + deniedTools: Array + hideTraceSpans: boolean + hideKnowledgeBaseTab: boolean + hideTablesTab: boolean + hideCopilot: boolean + hideIntegrationsTab: boolean + hideSecretsTab: boolean + hideApiKeysTab: boolean + hideInboxTab: boolean + hideFilesTab: boolean + disableMcpTools: boolean + disableCustomTools: boolean + disableSkills: boolean + disableInvitations: boolean + disablePublicApi: boolean + disablePublicFileSharing: boolean + allowedFileShareAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + hideDeployApi: boolean + hideDeployMcp: boolean + hideDeployChatbot: boolean + allowedChatDeployAuthTypes: Array<'public' | 'password' | 'email' | 'sso'> | null + disablePersonalApiKeys: boolean + disableLogExport: boolean + hideCostInfo: boolean + disableKnowledgeBaseCreation: boolean + disableKnowledgeBaseFileUpload: boolean + allowedKnowledgeConnectors: Array | null + disableTableCreation: boolean + disableTableExport: boolean + disableBulkFileDownload: boolean + disablePersonalCredentials: boolean + disableWorkspaceCreation: boolean + hideOrgMemberDirectory: boolean + disableCliAccess: boolean + disableWebhookTriggers: boolean + disableToolAutoApproval: boolean + hideSandboxesTab: boolean + disableOAuthAppAccess: boolean + disableKnowledgeBaseExport: boolean + } + isDefault: boolean + membershipMode: string + workspaceIds: Array + createdBy: string + createdAt: string + updatedAt: string +} + +export type UpdatePermissionGroupResponse = { + data: UpdatePermissionGroupResponseRef0 +} + /** `PATCH /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -11227,6 +11742,21 @@ export const V2_OPERATIONS = { summary: 'Activate Workflow Version', workspaceKeyUnsupported: true, }, + addPermissionGroupMember: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'Add Permission Group Member', + workspaceKeyUnsupported: true, + body: { + userId: { kind: 'string', required: true, describe: 'Existing organization member to add.' }, + }, + }, addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -11339,6 +11869,30 @@ export const V2_OPERATIONS = { }, }, }, + bulkAddPermissionGroupMembers: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'Bulk Add Permission Group Members', + workspaceKeyUnsupported: true, + body: { + userIds: { + kind: 'array', + describe: + 'Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.', + }, + addAllOrganizationMembers: { + kind: 'boolean', + describe: + 'Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.', + }, + }, + }, bulkDeleteFiles: { method: 'POST', path: '/api/v2/files/bulk-delete', @@ -12101,6 +12655,38 @@ export const V2_OPERATIONS = { }, }, }, + createPermissionGroup: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/permission-groups', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + responseMode: 'json', + summary: 'Create Permission Group', + workspaceKeyUnsupported: true, + body: { + name: { + kind: 'string', + required: true, + describe: 'Group name, unique within the organization.', + }, + description: { kind: 'string', describe: 'Optional group description.' }, + config: { + kind: 'object', + describe: + 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.', + }, + isDefault: { + kind: 'boolean', + describe: + 'Whether the group is the organization default. Only one group can be the default.', + }, + workspaceIds: { + kind: 'array', + describe: + 'Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.', + }, + }, + }, createSandbox: { method: 'POST', path: '/api/v2/sandboxes', @@ -12678,6 +13264,18 @@ export const V2_OPERATIONS = { }, }, }, + deletePermissionGroup: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'Delete Permission Group', + workspaceKeyUnsupported: true, + }, deleteSandbox: { method: 'DELETE', path: '/api/v2/sandboxes/[sandboxId]', @@ -13484,6 +14082,18 @@ export const V2_OPERATIONS = { }, }, }, + getPermissionGroup: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'Get Permission Group', + workspaceKeyUnsupported: true, + }, getRowEnrichment: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', @@ -15001,6 +15611,121 @@ export const V2_OPERATIONS = { }, }, }, + listPermissionGroupMembers: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'List Permission Group Members', + workspaceKeyUnsupported: true, + query: { + sortBy: { + kind: 'enum', + values: ['assignedAt', 'userId'] as const, + default: 'assignedAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum group members 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.', + }, + }, + }, + listPermissionGroups: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + responseMode: 'json', + summary: 'List Permission Groups', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the group name.', + }, + 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 permission groups 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.', + }, + }, + }, + listPermissionGroupWorkspaces: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/workspaces', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + responseMode: 'json', + summary: 'List Permission Group Workspaces', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the workspace name.', + }, + sortBy: { + kind: 'enum', + values: ['name', 'id'] as const, + default: 'name', + 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 organization 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.', + }, + }, + }, listSandboxes: { method: 'GET', path: '/api/v2/sandboxes', @@ -16329,6 +17054,19 @@ export const V2_OPERATIONS = { }, }, }, + removePermissionGroupMember: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]', + pathParams: ['organizationId', 'groupId', 'memberId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + memberId: 'Membership assignment identifier returned by List Permission Group Members.', + }, + responseMode: 'json', + summary: 'Remove Permission Group Member', + workspaceKeyUnsupported: true, + }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', @@ -17222,6 +17960,40 @@ export const V2_OPERATIONS = { }, }, }, + updatePermissionGroup: { + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'Update Permission Group', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'Group name, unique within the organization.' }, + description: { + kind: 'string', + describe: 'Group description. Null clears it; omission leaves it unchanged.', + }, + config: { + kind: 'object', + describe: + 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.', + }, + isDefault: { + kind: 'boolean', + describe: + 'Whether the group is the organization default. Only one group can be the default.', + }, + workspaceIds: { + kind: 'array', + describe: + 'Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.', + }, + }, + }, updateRowsByFilter: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 1b45a65a76a..0ada1461455 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1026,6 +1026,10 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'createPermissionGroup', + 'updatePermissionGroup', + 'addPermissionGroupMember', + 'bulkAddPermissionGroupMembers', 'forkWorkspace', 'getSelector', 'listSelector', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 810998289ae..f7aeec0f644 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -127,6 +127,67 @@ describe('commands parsed through commander', () => { profileState.workspaceId = 'ws_local' }) + describe('permission groups', () => { + it('lists organization groups without a workspace', async () => { + profileState.workspaceId = null + const [path, options] = await run(['permission-groups', 'list', '--organization', 'org-1']) + expect(path).toBe('/api/v2/organizations/org-1/permission-groups') + expect(options.query).not.toHaveProperty('workspaceId') + }) + + it('passes group configuration through the shared update operation', async () => { + const [path, options] = await run( + [ + 'permission-groups', + 'update', + 'group-1', + '--organization', + 'org-1', + '--config', + '{"disableCliAccess":true}', + ], + { data: {} } + ) + expect(path).toBe('/api/v2/organizations/org-1/permission-groups/group-1') + expect(options).toMatchObject({ + method: 'PATCH', + body: { config: { disableCliAccess: true } }, + }) + }) + + it('adds a member with explicit organization and group scope', async () => { + const [path, options] = await run( + [ + 'permission-groups', + 'members', + 'add', + '--organization', + 'org-1', + '--group', + 'group-1', + '--user-id', + 'user-1', + ], + { data: { id: 'assignment-1' } } + ) + expect(path).toBe('/api/v2/organizations/org-1/permission-groups/group-1/members') + expect(options).toMatchObject({ method: 'POST', body: { userId: 'user-1' } }) + }) + + it('requires confirmation to delete a group', async () => { + await expect( + run(['permission-groups', 'delete', 'group-1', '--organization', 'org-1']) + ).rejects.toThrow(/--yes/) + expect(mockRequest).not.toHaveBeenCalled() + const [path, options] = await run( + ['permission-groups', 'delete', 'group-1', '--organization', 'org-1', '--yes'], + { data: { id: 'group-1', deleted: true } } + ) + expect(path).toBe('/api/v2/organizations/org-1/permission-groups/group-1') + expect(options.method).toBe('DELETE') + }) + }) + it('carries a multi-word flag all the way to the request', async () => { // The regression: commander stores this as `minDurationMs`, so a lookup by // `min-duration-ms` found nothing and the filter never reached the API. diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index b4f91523487..dd5b1a9681a 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -20,6 +20,7 @@ const GROUP_ALIASES: Readonly> = { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', + 'permission-groups': 'permission-group', files: 'file', knowledge: 'kb', logs: 'log', diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index fcbe18af859..18397fe8616 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -20,6 +20,9 @@ const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listChatDeployments', 'listCredentials', 'listCustomTools', + 'listPermissionGroups', + 'listPermissionGroupMembers', + 'listPermissionGroupWorkspaces', 'listFiles', 'listKnowledgeBases', 'listKnowledgeConnectors', diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 8bcd21afa02..957780771da 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -114,7 +114,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 53], ['apps/docs/openapi-v2-knowledge.json', 45], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 51], + ['apps/docs/openapi-v2-resources.json', 61], ]) const generatedDocuments = new Map<(typeof DOCUMENTS)[number], JsonObject>() @@ -310,7 +310,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(247) + expect(totalOperations).toBe(257) }) it('documents mixed workflow execution and resume responses', () => { From f332ca62b1c97bd290841b0d313fa37102afd6d9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 01:12:49 -0700 Subject: [PATCH 2/7] fix(permission-groups): preserve bulk membership behavior and locked entitlement checks --- .../content/docs/cli/permission-groups.mdx | 4 +- apps/docs/content/docs/cli/reference.mdx | 4 +- apps/docs/lib/openapi-download.test.ts | 3 +- apps/docs/openapi-v2-resources.json | 8 +- .../permission-groups/[groupId]/route.test.ts | 8 +- .../lib/api/contracts/permission-groups.ts | 8 +- .../contracts/v2/openapi/permission-groups.ts | 14 +- .../lib/api/contracts/v2/permission-groups.ts | 4 +- .../lib/api/mcp/generated/v2-operations.ts | 2 +- .../application/use-cases.ts | 2 + .../permission-groups/group-manager.test.ts | 16 ++ .../lib/permission-groups/group-manager.ts | 13 +- .../permission-groups/member-manager.test.ts | 65 ++++++-- .../lib/permission-groups/member-manager.ts | 140 +++++++++--------- .../lib/permission-groups/mutation.test.ts | 53 +++++++ apps/sim/lib/permission-groups/mutation.ts | 23 +++ packages/sim-cli/src/generated/v2-api.ts | 5 +- packages/sim-cli/src/runtime/build.test.ts | 8 + 18 files changed, 265 insertions(+), 115 deletions(-) create mode 100644 apps/sim/lib/permission-groups/mutation.test.ts create mode 100644 apps/sim/lib/permission-groups/mutation.ts diff --git a/apps/docs/content/docs/cli/permission-groups.mdx b/apps/docs/content/docs/cli/permission-groups.mdx index 5e00885d9f8..886849480e3 100644 --- a/apps/docs/content/docs/cli/permission-groups.mdx +++ b/apps/docs/content/docs/cli/permission-groups.mdx @@ -46,7 +46,7 @@ Bulk Add Permission Group Members (OAuth login or personal API key required) | `--organization ` | Yes | Organization that owns the permission group. | | `--group ` | Yes | Permission group identifier. | | `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | -| `--add-all-organization-members` | No | Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds. | +| `--add-all-organization-members` | No | Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds. | | `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | @@ -254,7 +254,7 @@ Update Permission Group (OAuth login or personal API key required) | --- | --- | --- | | `--organization ` | Yes | Organization that owns the permission group. | | `--name ` | No | Group name, unique within the organization. | -| `--description ` | No | Group description. Null clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | +| `--description ` | No | Group description. Null or an empty string clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | | `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | | `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | | `--no-is-default` | No | Send --is-default as false. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index d317bf8f0a0..a9c2cfe4119 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -3037,7 +3037,7 @@ sim permission-groups members batch-add [options] | `--organization ` | Yes | Organization that owns the permission group. | | `--group ` | Yes | Permission group identifier. | | `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | -| `--add-all-organization-members` | No | Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds. | +| `--add-all-organization-members` | No | Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds. | | `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | @@ -3245,7 +3245,7 @@ sim permission-groups update [options] | --- | --- | --- | | `--organization ` | Yes | Organization that owns the permission group. | | `--name ` | No | Group name, unique within the organization. | -| `--description ` | No | Group description. Null clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | +| `--description ` | No | Group description. Null or an empty string clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | | `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | | `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | | `--no-is-default` | No | Send --is-default as false. | diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 5fccfe6ce59..437bf2956a3 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,7 +33,7 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(157) + expect(Object.keys(paths)).toHaveLength(163) expect(tags.map((tag) => tag.name)).toEqual([ 'Workspace Sync', 'Workflows', @@ -44,6 +44,7 @@ describe('OpenAPI download', () => { 'Tables', 'Knowledge Bases', 'Billing', + 'Permission Groups', 'Meta', 'Workspaces', 'MCP Servers', diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index bb782cf1f8b..3ce08b0208a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -5524,7 +5524,7 @@ "post": { "operationId": "bulkAddPermissionGroupMembers", "summary": "Bulk Add Permission Group Members", - "description": "Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Assign up to 1000 selected organization members, or the entire organization roster, atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "permission_groups.members.bulk_add", "x-oauth-scope": "api:write", "tags": ["Permission Groups"], @@ -12628,7 +12628,7 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this is the organization default, which applies across all its workspaces." + "description": "Whether this is the organization default, which applies to everyone across all its workspaces regardless of member assignments." }, "membershipMode": { "type": "string", @@ -13266,7 +13266,7 @@ "description": "Group name, unique within the organization." }, "description": { - "description": "Group description. Null clears it; omission leaves it unchanged.", + "description": "Group description. Null or an empty string clears it; omission leaves it unchanged.", "anyOf": [ { "type": "string", @@ -13847,7 +13847,7 @@ } }, "addAllOrganizationMembers": { - "description": "Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.", + "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", "type": "boolean" } }, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts index b732750d5ca..6197aea0283 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts @@ -77,7 +77,9 @@ async function updateUnderLock(body: UpdatePermissionGroupBody) { }) try { expect(await Promise.race([lockEntered.promise, pendingResponse.then(() => false)])).toBe(true) - expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) + expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID, { + lockTimeoutAlreadyBounded: true, + }) expect(dbChainMockFns.update).not.toHaveBeenCalled() } finally { lockReleased.resolve() @@ -161,7 +163,9 @@ describe('permission group PUT policy serialization', () => { expect(response.status).toBe(404) await expect(response.json()).resolves.toMatchObject({ error: 'Permission group not found' }) - expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) + expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID, { + lockTimeoutAlreadyBounded: true, + }) expect(mocks.loadGroup).toHaveBeenLastCalledWith(GROUP_ID, ORGANIZATION_ID, db) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 60c819adfb8..96f3b4b3332 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -114,10 +114,10 @@ const workspaceIdsSchema = z.array(z.string().min(1)).max(MAX_PERMISSION_GROUP_W * with no `workspaceIds` is already the all-workspaces case and needs no * assertion here. * - * Everything else is left to the routes: a non-default group targets the + * Other scope rules are enforced by the shared manager: a non-default group targets the * workspaces in `workspaceIds` (empty is allowed on update — the group then * governs nothing, since the resolver inner-joins the workspace link table), and - * the create route requires at least one workspace up front. + * creation requires at least one workspace up front. */ function refineWorkspaceScope( body: { workspaceIds?: string[]; isDefault?: boolean }, @@ -177,7 +177,9 @@ export const updatePermissionGroupBodySchema = z .max(500, 'description cannot exceed 500 characters') .nullable() .optional() - .describe('Group description. Null clears it; omission leaves it unchanged.'), + .describe( + 'Group description. Null or an empty string clears it; omission leaves it unchanged.' + ), config: permissionGroupConfigSchema.optional(), isDefault: z .boolean() diff --git a/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts index e8502c2869e..9eddf91ad13 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts @@ -61,8 +61,8 @@ export const permissionGroupOpenApiRoutes = [ params: documentedSchema( v2ListPermissionGroupsContract.params, 'ListPermissionGroupsParams', - 'Permission group parameters', - 'Organization and permission-group identifiers.' + 'Organization parameters', + 'Organization identifier.' ), query: documentedSchema( v2ListPermissionGroupsContract.query, @@ -94,8 +94,8 @@ export const permissionGroupOpenApiRoutes = [ params: documentedSchema( v2CreatePermissionGroupContract.params, 'CreatePermissionGroupParams', - 'Permission group parameters', - 'Organization and permission-group identifiers.' + 'Organization parameters', + 'Organization identifier.' ), query: v2CreatePermissionGroupContract.query, body: documentedSchema( @@ -324,7 +324,7 @@ export const permissionGroupOpenApiRoutes = [ applicationOperation: permissionGroupOperations.bulkAddMembers, operationId: 'bulkAddPermissionGroupMembers', summary: 'Bulk Add Permission Group Members', - description: `Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. ${AUTHORITY}`, + description: `Assign up to 1000 selected organization members, or the entire organization roster, atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. ${AUTHORITY}`, tags: ['Permission Groups'], errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { @@ -374,8 +374,8 @@ export const permissionGroupOpenApiRoutes = [ params: documentedSchema( v2ListPermissionGroupWorkspacesContract.params, 'ListPermissionGroupWorkspacesParams', - 'Permission group parameters', - 'Organization and permission-group identifiers.' + 'Organization parameters', + 'Organization identifier.' ), query: documentedSchema( v2ListPermissionGroupWorkspacesContract.query, diff --git a/apps/sim/lib/api/contracts/v2/permission-groups.ts b/apps/sim/lib/api/contracts/v2/permission-groups.ts index 7ede510d75b..5449c8ac2a9 100644 --- a/apps/sim/lib/api/contracts/v2/permission-groups.ts +++ b/apps/sim/lib/api/contracts/v2/permission-groups.ts @@ -50,7 +50,7 @@ export const v2PermissionGroupSchema = z isDefault: z .boolean() .describe( - 'Whether this is the organization default, which applies across all its workspaces.' + 'Whether this is the organization default, which applies to everyone across all its workspaces regardless of member assignments.' ), membershipMode: z .string() @@ -165,7 +165,7 @@ export const v2BulkAddPermissionGroupMembersBodySchema = z .boolean() .optional() .describe( - 'Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.' + 'Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.' ), }) .strict() diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 20d8bdee591..003ca40ce21 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -387,7 +387,7 @@ export const V2_MCP_OPERATIONS = { contract: v2BulkAddPermissionGroupMembersContract, summary: 'Bulk Add Permission Group Members', description: - 'Assign up to 1000 organization members atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Assign up to 1000 selected organization members, or the entire organization roster, atomically. Existing assignments are skipped and users outside the organization are ignored. Any overlapping membership conflict rejects the entire batch. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import( diff --git a/apps/sim/lib/permission-groups/application/use-cases.ts b/apps/sim/lib/permission-groups/application/use-cases.ts index d3e3aca70ed..964c6b2042c 100644 --- a/apps/sim/lib/permission-groups/application/use-cases.ts +++ b/apps/sim/lib/permission-groups/application/use-cases.ts @@ -181,7 +181,9 @@ export const bulkAddPermissionGroupMembers = defineAuthorizedPermissionGroupUseC description: `Bulk added ${result.added} member(s) to permission group "${result.group.name}"`, metadata: { permissionGroupId: result.group.id, + added: result.added, addedUserIds: result.addedUserIds, + ...(result.added > result.addedUserIds.length && { addedUserIdsTruncated: true }), skipped: result.skipped, }, }, diff --git a/apps/sim/lib/permission-groups/group-manager.test.ts b/apps/sim/lib/permission-groups/group-manager.test.ts index 7840591e769..2398ac7e92d 100644 --- a/apps/sim/lib/permission-groups/group-manager.test.ts +++ b/apps/sim/lib/permission-groups/group-manager.test.ts @@ -12,6 +12,12 @@ const mocks = vi.hoisted(() => ({ allConflict: vi.fn(), scopeConflicts: vi.fn(), })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: vi.fn().mockResolvedValue(true), +})) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.lock })) vi.mock('@/lib/permission-groups/repository', () => ({ loadGroupInOrganization: mocks.group, @@ -55,6 +61,16 @@ beforeEach(() => { }) describe('permission group mutation consistency', () => { + it('accepts an explicitly empty workspace scope when promoting the default', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ ...group, isDefault: true }]) + const result = await updatePermissionGroupRecord('org-1', 'group-1', { + isDefault: true, + workspaceIds: [], + }) + expect(result).toMatchObject({ isDefault: true, workspaceIds: [] }) + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + }) + it('reads default state only after acquiring the organization lock', async () => { const entered = Promise.withResolvers() const released = Promise.withResolvers() diff --git a/apps/sim/lib/permission-groups/group-manager.ts b/apps/sim/lib/permission-groups/group-manager.ts index fe5b43917ac..3c492e42f09 100644 --- a/apps/sim/lib/permission-groups/group-manager.ts +++ b/apps/sim/lib/permission-groups/group-manager.ts @@ -17,7 +17,7 @@ import { type PermissionGroupConfig, parsePermissionGroupConfig, } from '@/lib/permission-groups/fields' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { withPermissionGroupMutation } from '@/lib/permission-groups/mutation' import { findWorkspacesNotInOrganization, getGroupWorkspaces, @@ -114,8 +114,7 @@ export async function createPermissionGroupRecord( 'validation', 'Select at least one workspace when the group targets specific workspaces' ) - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { await validateWorkspaces(organizationId, workspaceIds, tx) await assertAvailableName(organizationId, input.name, tx) const now = new Date() @@ -151,13 +150,12 @@ export async function updatePermissionGroupRecord( groupId: string, updates: PermissionGroupChanges ) { - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) if (updates.name !== undefined) await assertAvailableName(organizationId, updates.name, tx, groupId) const isDefault = updates.isDefault ?? group.isDefault - if (isDefault && updates.workspaceIds !== undefined) + if (isDefault && updates.workspaceIds?.length) throw new OrchestrationError( 'validation', 'The default group governs all workspaces and cannot target specific workspaces' @@ -229,8 +227,7 @@ export async function updatePermissionGroupRecord( } export async function deletePermissionGroupRecord(organizationId: string, groupId: string) { - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) await tx .delete(permissionGroupMember) diff --git a/apps/sim/lib/permission-groups/member-manager.test.ts b/apps/sim/lib/permission-groups/member-manager.test.ts index bef4027652e..f38ca1ac9bc 100644 --- a/apps/sim/lib/permission-groups/member-manager.test.ts +++ b/apps/sim/lib/permission-groups/member-manager.test.ts @@ -9,6 +9,12 @@ const mocks = vi.hoisted(() => ({ allConflict: vi.fn(), scopeConflicts: vi.fn(), })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: vi.fn().mockResolvedValue(true), +})) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: vi.fn() })) vi.mock('@/lib/permission-groups/group-manager', () => ({ requirePermissionGroup: mocks.group })) vi.mock('@/lib/permission-groups/repository', () => ({ getGroupWorkspaces: mocks.workspaces })) @@ -56,16 +62,21 @@ describe('permission-group membership mutations', () => { ).rejects.toMatchObject({ code: 'conflict' }) expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) - it('records the actual assigning user', async () => { - queueTableRows(member, [{ id: 'org-member-1' }]) - const result = await addPermissionGroupMemberRecord('org-1', 'group-1', 'member-1', 'admin-1') - expect(result.member).toMatchObject({ - userId: 'member-1', - assignedBy: 'admin-1', - organizationId: 'org-1', - permissionGroupId: 'group-1', - }) - }) + it.each([false, true])( + 'retains assignments and the actual actor for isDefault=%s', + async (isDefault) => { + mocks.group.mockResolvedValue({ ...group, isDefault }) + if (isDefault) mocks.workspaces.mockResolvedValue([]) + queueTableRows(member, [{ id: 'org-member-1' }]) + const result = await addPermissionGroupMemberRecord('org-1', 'group-1', 'member-1', 'admin-1') + expect(result.member).toMatchObject({ + userId: 'member-1', + assignedBy: 'admin-1', + organizationId: 'org-1', + permissionGroupId: 'group-1', + }) + } + ) it('rejects the entire bulk selection on a membership overlap', async () => { queueTableRows(member, [{ userId: 'member-1' }, { userId: 'member-2' }]) mocks.scopeConflicts.mockResolvedValue([{ userName: 'Member', conflictingGroupName: 'Other' }]) @@ -93,11 +104,34 @@ describe('permission-group membership mutations', () => { expect.objectContaining({ userId: 'member-2', assignedBy: 'admin-1' }), ]) }) - it('bounds an add-all expansion before materializing writes', async () => { + it('adds a large organization in bounded batches within one transaction', async () => { + queueTableRows( + member, + Array.from({ length: 1000 }, (_, index) => ({ userId: `member-${index}` })) + ) + queueTableRows(member, [{ userId: 'member-last' }]) + const result = await bulkAddPermissionGroupMemberRecords( + 'org-1', + 'group-1', + { addAllOrganizationMembers: true }, + 'admin-1' + ) + expect(result).toMatchObject({ added: 1001, skipped: 0 }) + expect(result.addedUserIds).toHaveLength(1000) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.limit.mock.calls).toEqual([[1000], [1000]]) + expect(dbChainMockFns.values.mock.calls.map(([rows]) => rows.length)).toEqual([1000, 1]) + }) + + it('keeps a conflict on a later batch inside the same rollback boundary', async () => { queueTableRows( member, - Array.from({ length: 1001 }, (_, index) => ({ userId: `member-${index}` })) + Array.from({ length: 1000 }, (_, index) => ({ userId: `member-${index}` })) ) + queueTableRows(member, [{ userId: 'member-last' }]) + mocks.scopeConflicts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ userName: 'Member', conflictingGroupName: 'Other' }]) await expect( bulkAddPermissionGroupMemberRecords( 'org-1', @@ -105,10 +139,11 @@ describe('permission-group membership mutations', () => { { addAllOrganizationMembers: true }, 'admin-1' ) - ).rejects.toMatchObject({ code: 'payload_too_large' }) - expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(1001) - expect(dbChainMockFns.insert).not.toHaveBeenCalled() + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + expect(dbChainMockFns.insert).toHaveBeenCalledOnce() }) + it('will not expand the last-member removal into an overlapping all-member scope', async () => { queueTableRows(permissionGroupMember, [{ id: 'assignment-1', userId: 'member-1', email: null }]) queueTableRows(permissionGroupMember, [{ value: 1 }]) diff --git a/apps/sim/lib/permission-groups/member-manager.ts b/apps/sim/lib/permission-groups/member-manager.ts index db05d4de99e..023770f91fd 100644 --- a/apps/sim/lib/permission-groups/member-manager.ts +++ b/apps/sim/lib/permission-groups/member-manager.ts @@ -1,7 +1,7 @@ -import { db } from '@sim/db' import { member, permissionGroupMember, user } from '@sim/db/schema' +import { chunkArray } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { and, count, eq, inArray } from 'drizzle-orm' +import { and, asc, count, eq, gt, inArray } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { findAllMembersWorkspaceConflict, @@ -13,7 +13,7 @@ import { formatScopeConflictError, } from '@/lib/permission-groups/errors' import { requirePermissionGroup } from '@/lib/permission-groups/group-manager' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { withPermissionGroupMutation } from '@/lib/permission-groups/mutation' import { getGroupWorkspaces } from '@/lib/permission-groups/repository' export async function addPermissionGroupMemberRecord( @@ -22,8 +22,7 @@ export async function addPermissionGroupMemberRecord( userId: string, actorUserId: string ) { - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) const [organizationMember] = await tx .select({ id: member.id }) @@ -69,8 +68,7 @@ export async function removePermissionGroupMemberRecord( groupId: string, memberId: string ) { - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) const [assignment] = await tx .select({ @@ -128,68 +126,78 @@ export async function bulkAddPermissionGroupMemberRecords( input: BulkAddPermissionGroupMembersInput, actorUserId: string ) { - const uniqueUserIds = [...new Set(input.userIds ?? [])] - if (!input.addAllOrganizationMembers && uniqueUserIds.length > MAX_PERMISSION_GROUP_BULK_MEMBERS) - throw new OrchestrationError( - 'validation', - 'userIds cannot exceed 1000; split the members into batches' - ) - return db.transaction(async (tx) => { - await acquirePermissionGroupOrgLock(tx, organizationId) + return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) - if (!input.addAllOrganizationMembers && !uniqueUserIds.length) - return { group, addedUserIds: [] as string[], added: 0, skipped: 0 } - const candidates = await tx - .select({ userId: member.userId }) - .from(member) - .where( - and( - eq(member.organizationId, organizationId), - input.addAllOrganizationMembers ? undefined : inArray(member.userId, uniqueUserIds) - ) - ) - .limit(MAX_PERMISSION_GROUP_BULK_MEMBERS + 1) - if (candidates.length > MAX_PERMISSION_GROUP_BULK_MEMBERS) - throw new OrchestrationError( - 'payload_too_large', - 'The organization has more than 1000 members; add members in batches using userIds' - ) - const targetUserIds = [...new Set(candidates.map((candidate) => candidate.userId))] - if (!targetUserIds.length) return { group, addedUserIds: [] as string[], added: 0, skipped: 0 } const workspaceIds = (await getGroupWorkspaces(groupId, tx)).map((workspace) => workspace.id) - const conflicts = await findScopeConflicts( - { organizationId, excludeGroupId: groupId, workspaceIds, candidateUserIds: targetUserIds }, - tx - ) - if (conflicts.length) - throw new OrchestrationError('conflict', formatScopeConflictError(conflicts)) - const existing = await tx - .select({ userId: permissionGroupMember.userId }) - .from(permissionGroupMember) - .where( - and( - eq(permissionGroupMember.permissionGroupId, groupId), - inArray(permissionGroupMember.userId, targetUserIds) - ) - ) - const existingIds = new Set(existing.map((assignment) => assignment.userId)) - const addedUserIds = targetUserIds.filter((userId) => !existingIds.has(userId)) - if (addedUserIds.length) - await tx.insert(permissionGroupMember).values( - addedUserIds.map((userId) => ({ - id: generateId(), - permissionGroupId: groupId, - organizationId, - userId, - assignedBy: actorUserId, - assignedAt: new Date(), - })) + const addedUserIds: string[] = [] + let added = 0 + let skipped = 0 + + async function addBatch(targetUserIds: string[]) { + if (!targetUserIds.length) return + const conflicts = await findScopeConflicts( + { organizationId, excludeGroupId: groupId, workspaceIds, candidateUserIds: targetUserIds }, + tx ) - return { - group, - addedUserIds, - added: addedUserIds.length, - skipped: targetUserIds.length - addedUserIds.length, + if (conflicts.length) + throw new OrchestrationError('conflict', formatScopeConflictError(conflicts)) + const existing = await tx + .select({ userId: permissionGroupMember.userId }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.permissionGroupId, groupId), + inArray(permissionGroupMember.userId, targetUserIds) + ) + ) + const existingIds = new Set(existing.map((assignment) => assignment.userId)) + const batch = targetUserIds.filter((userId) => !existingIds.has(userId)) + if (batch.length) + await tx.insert(permissionGroupMember).values( + batch.map((userId) => ({ + id: generateId(), + permissionGroupId: groupId, + organizationId, + userId, + assignedBy: actorUserId, + assignedAt: new Date(), + })) + ) + added += batch.length + skipped += targetUserIds.length - batch.length + addedUserIds.push(...batch.slice(0, MAX_PERMISSION_GROUP_BULK_MEMBERS - addedUserIds.length)) + } + + if (input.addAllOrganizationMembers) { + let afterUserId: string | undefined + while (true) { + const candidates = await tx + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + afterUserId === undefined ? undefined : gt(member.userId, afterUserId) + ) + ) + .orderBy(asc(member.userId)) + .limit(MAX_PERMISSION_GROUP_BULK_MEMBERS) + await addBatch(candidates.map((candidate) => candidate.userId)) + if (candidates.length < MAX_PERMISSION_GROUP_BULK_MEMBERS) break + afterUserId = candidates[candidates.length - 1].userId + } + } else { + for (const selected of chunkArray( + [...new Set(input.userIds ?? [])], + MAX_PERMISSION_GROUP_BULK_MEMBERS + )) { + const candidates = await tx + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), inArray(member.userId, selected))) + await addBatch(candidates.map((candidate) => candidate.userId)) + } } + return { group, addedUserIds, added, skipped } }) } diff --git a/apps/sim/lib/permission-groups/mutation.test.ts b/apps/sim/lib/permission-groups/mutation.test.ts new file mode 100644 index 00000000000..5b7eb5e935b --- /dev/null +++ b/apps/sim/lib/permission-groups/mutation.test.ts @@ -0,0 +1,53 @@ +/** @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ organizationLock: vi.fn(), groupLock: vi.fn(), regime: vi.fn() })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.organizationLock, +})) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mocks.regime, +})) + +import { withPermissionGroupMutation } from '@/lib/permission-groups/mutation' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.regime.mockResolvedValue(true) +}) + +describe('permission group mutation entitlement', () => { + it('rechecks live entitlement using the locked transaction before mutating', async () => { + const mutate = vi.fn().mockResolvedValue('result') + await expect(withPermissionGroupMutation('org-1', mutate)).resolves.toBe('result') + const tx = mutate.mock.calls[0][0] + expect(mocks.organizationLock).toHaveBeenCalledWith(tx, 'org-1') + expect(mocks.groupLock).toHaveBeenCalledWith(tx, 'org-1', { lockTimeoutAlreadyBounded: true }) + expect(mocks.regime).toHaveBeenCalledWith('org-1', tx) + expect(mocks.organizationLock.mock.invocationCallOrder[0]).toBeLessThan( + mocks.groupLock.mock.invocationCallOrder[0] + ) + expect(mocks.groupLock.mock.invocationCallOrder[0]).toBeLessThan( + mocks.regime.mock.invocationCallOrder[0] + ) + expect(mocks.regime.mock.invocationCallOrder[0]).toBeLessThan( + mutate.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + }) + + it('refuses a mutation when entitlement changed while waiting for the lock', async () => { + const gate = Promise.withResolvers() + mocks.organizationLock.mockImplementationOnce(() => gate.promise) + const mutate = vi.fn() + const result = withPermissionGroupMutation('org-1', mutate) + expect(mocks.regime).not.toHaveBeenCalled() + mocks.regime.mockResolvedValue(false) + gate.resolve() + await expect(result).rejects.toMatchObject({ detailCode: 'ENTERPRISE_PLAN_REQUIRED' }) + expect(mutate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-groups/mutation.ts b/apps/sim/lib/permission-groups/mutation.ts new file mode 100644 index 00000000000..83610348783 --- /dev/null +++ b/apps/sim/lib/permission-groups/mutation.ts @@ -0,0 +1,23 @@ +import { db } from '@sim/db' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import type { DbOrTx } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' + +/** Holds entitlement and group state stable for the entire mutation. */ +export function withPermissionGroupMutation( + organizationId: string, + mutate: (tx: DbOrTx) => Promise +): Promise { + return db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, organizationId) + await acquirePermissionGroupOrgLock(tx, organizationId, { lockTimeoutAlreadyBounded: true }) + if (!(await isOrganizationPermissionRegimeActive(organizationId, tx))) + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', + 'Access Control is an Enterprise feature' + ) + return mutate(tx) + }) +} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f1eb6359b13..d2b5c7408cb 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -11889,7 +11889,7 @@ export const V2_OPERATIONS = { addAllOrganizationMembers: { kind: 'boolean', describe: - 'Add every current organization member, up to 1000. For larger organizations, use userIds in batches. Cannot be combined with userIds.', + 'Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.', }, }, }, @@ -17975,7 +17975,8 @@ export const V2_OPERATIONS = { name: { kind: 'string', describe: 'Group name, unique within the organization.' }, description: { kind: 'string', - describe: 'Group description. Null clears it; omission leaves it unchanged.', + describe: + 'Group description. Null or an empty string clears it; omission leaves it unchanged.', }, config: { kind: 'object', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index f7aeec0f644..32e7a2d5a34 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -155,6 +155,14 @@ describe('commands parsed through commander', () => { }) }) + it('clears a description using the standard empty string flag', async () => { + const [, options] = await run( + ['permission-groups', 'update', 'group-1', '--organization', 'org-1', '--description', ''], + { data: {} } + ) + expect(options.body).toEqual({ description: '' }) + }) + it('adds a member with explicit organization and group scope', async () => { const [path, options] = await run( [ From 96bf07f0cc458b2f2145a7c042bd6f8f7269785b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 10:04:00 -0700 Subject: [PATCH 3/7] feat(organizations): align administration across API CLI and MCP --- .../docs/content/docs/api-reference/meta.json | 1 + apps/docs/content/docs/cli/commands.mdx | 1 + apps/docs/content/docs/cli/meta.json | 1 + apps/docs/content/docs/cli/organizations.mdx | 278 + .../content/docs/cli/permission-groups.mdx | 63 +- apps/docs/content/docs/cli/reference.mdx | 339 +- apps/docs/content/docs/cli/tables.mdx | 4 +- apps/docs/openapi-v2-resources.json | 6729 ++++++++++------- .../api/invitations/[id]/resend/route.test.ts | 40 +- .../app/api/invitations/[id]/resend/route.ts | 243 +- apps/sim/app/api/invitations/[id]/route.ts | 156 +- .../[id]/members/[memberId]/route.ts | 507 +- .../organizations/[id]/members/route.test.ts | 7 +- .../api/organizations/[id]/members/route.ts | 251 +- apps/sim/app/api/organizations/[id]/route.ts | 148 +- .../[invitationId]/resend/route.ts | 21 + .../invitations/[invitationId]/route.ts | 38 + .../[organizationId]/invitations/route.ts | 57 + .../members/[userId]/route.ts | 34 + .../[organizationId]/members/route.ts | 40 + .../members/{[memberId] => [userId]}/route.ts | 2 +- .../permission-groups/workspaces/route.ts | 41 - .../organizations/[organizationId]/route.ts | 17 + .../[organizationId]/workspaces/route.ts | 39 + .../app/api/v2/organizations/route.test.ts | 169 + apps/sim/app/api/v2/organizations/route.ts | 35 + .../api/workspaces/invitations/batch/route.ts | 6 +- apps/sim/lib/api/contracts/organization.ts | 36 + .../v2/__tests__/list-pagination.test.ts | 21 +- .../api/contracts/v2/openapi/organizations.ts | 490 ++ .../contracts/v2/openapi/permission-groups.ts | 41 +- .../lib/api/contracts/v2/openapi/resources.ts | 6 + .../sim/lib/api/contracts/v2/organizations.ts | 288 + .../lib/api/contracts/v2/permission-groups.ts | 61 +- .../lib/api/mcp/generated/v2-operations.ts | 145 +- .../lib/api/server/organization-presenters.ts | 60 + .../lib/api/server/routes/organizations.ts | 69 + .../membership-external-removal.test.ts | 45 + .../lib/billing/organizations/membership.ts | 28 +- .../lib/copilot/generated/docs-manifest.ts | 1 + .../authorized-organization-use-case.ts | 77 + .../application/organization-authorization.ts | 14 +- .../application/authorize-mutation.ts | 104 + .../invitations/application/mutations.test.ts | 164 + .../lib/invitations/application/mutations.ts | 120 + .../lib/invitations/application/operations.ts | 50 + .../application/send-invitation-batch.test.ts | 11 +- .../application/send-invitation-batch.ts | 27 +- apps/sim/lib/invitations/core.ts | 36 +- apps/sim/lib/invitations/mutation-manager.ts | 155 + .../organization-invitations.test.ts | 5 +- .../invitations/organization-invitations.ts | 38 +- apps/sim/lib/invitations/send-resend.test.ts | 85 + apps/sim/lib/invitations/send.test.ts | 35 +- apps/sim/lib/invitations/send.ts | 82 +- .../organizations/application/invitations.ts | 58 + .../lib/organizations/application/members.ts | 86 + .../organizations/application/operations.ts | 91 + .../lib/organizations/application/reads.ts | 164 + .../application/use-cases.test.ts | 328 + .../lib/organizations/member-manager.test.ts | 103 + apps/sim/lib/organizations/member-manager.ts | 114 + apps/sim/lib/organizations/member-queries.ts | 83 + .../lib/organizations/members/authority.ts | 26 + .../organizations/members/lifecycle.test.ts | 15 + .../lib/organizations/members/revocation.ts | 10 +- apps/sim/lib/organizations/queries.ts | 231 + .../authorized-permission-group-use-case.ts | 3 +- .../application/use-cases.ts | 5 +- .../permission-groups/member-manager.test.ts | 44 +- .../lib/permission-groups/member-manager.ts | 10 +- packages/sim-cli/src/contract/commands.ts | 103 +- packages/sim-cli/src/generated/v2-api.ts | 566 +- packages/sim-cli/src/http/client.test.ts | 3 + packages/sim-cli/src/runtime/build.test.ts | 117 +- packages/sim-cli/src/runtime/options.ts | 9 +- scripts/openapi/documents.test.ts | 4 +- 77 files changed, 9652 insertions(+), 4082 deletions(-) create mode 100644 apps/docs/content/docs/cli/organizations.mdx create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/invitations/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/members/route.ts rename apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/{[memberId] => [userId]}/route.ts (89%) delete mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/workspaces/route.ts create mode 100644 apps/sim/app/api/v2/organizations/route.test.ts create mode 100644 apps/sim/app/api/v2/organizations/route.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/organizations.ts create mode 100644 apps/sim/lib/api/contracts/v2/organizations.ts create mode 100644 apps/sim/lib/api/server/organization-presenters.ts create mode 100644 apps/sim/lib/api/server/routes/organizations.ts create mode 100644 apps/sim/lib/core/application/authorized-organization-use-case.ts create mode 100644 apps/sim/lib/invitations/application/authorize-mutation.ts create mode 100644 apps/sim/lib/invitations/application/mutations.test.ts create mode 100644 apps/sim/lib/invitations/application/mutations.ts create mode 100644 apps/sim/lib/invitations/application/operations.ts create mode 100644 apps/sim/lib/invitations/mutation-manager.ts create mode 100644 apps/sim/lib/invitations/send-resend.test.ts create mode 100644 apps/sim/lib/organizations/application/invitations.ts create mode 100644 apps/sim/lib/organizations/application/members.ts create mode 100644 apps/sim/lib/organizations/application/operations.ts create mode 100644 apps/sim/lib/organizations/application/reads.ts create mode 100644 apps/sim/lib/organizations/application/use-cases.test.ts create mode 100644 apps/sim/lib/organizations/member-manager.test.ts create mode 100644 apps/sim/lib/organizations/member-manager.ts create mode 100644 apps/sim/lib/organizations/member-queries.ts create mode 100644 apps/sim/lib/organizations/members/authority.ts create mode 100644 apps/sim/lib/organizations/queries.ts diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index 0bc4f2fc0e0..bd9a0920315 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -17,6 +17,7 @@ "(generated)/files", "(generated)/knowledge-bases", "(generated)/workspaces", + "(generated)/organizations", "(generated)/permission-groups", "(generated)/workspace-sync", "(generated)/mcp-servers", diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index 676fc6b31da..e63345e7a0f 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -44,6 +44,7 @@ These apply to every command, and may be written before or after it. | [`sim logs`](/cli/logs) | Manage logs | | [`sim mcp-servers`](/cli/mcp-servers) | Manage mcp servers | | [`sim meta`](/cli/meta) | Manage meta | +| [`sim organizations`](/cli/organizations) | Manage organizations | | [`sim permission-groups`](/cli/permission-groups) | Manage permission groups | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 842ea3a5084..0ba739e7a34 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -27,6 +27,7 @@ "logs", "mcp-servers", "meta", + "organizations", "permission-groups", "sandboxes", "secrets", diff --git a/apps/docs/content/docs/cli/organizations.mdx b/apps/docs/content/docs/cli/organizations.mdx new file mode 100644 index 00000000000..07b9f42b668 --- /dev/null +++ b/apps/docs/content/docs/cli/organizations.mdx @@ -0,0 +1,278 @@ +--- +title: Organizations +description: Manage organizations — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Create organization invitation + +```bash +sim organizations invitations create [options] +``` + +Create Organization Invitation (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--email ` | Yes | Email address of the person to invite. | +| `--role ` | No | Organization role to offer. Defaults to member; grants no workspace-specific permissions. Accepted values: `member`, `admin`. | + + + +## Get organization invitation + +```bash +sim organizations invitations get [options] +``` + +Get Organization Invitation (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## List organization invitations + +```bash +sim organizations invitations list [options] +``` + +List Organization Invitations (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the invitee email. | +| `--status ` | No | Filter by current invitation status. Omit to include all statuses. Accepted values: `pending`, `accepted`, `rejected`, `cancelled`, `expired`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `email`, `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Resend organization invitation + +```bash +sim organizations invitations resend [options] +``` + +Resend Organization Invitation (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Revoke organization invitation + +```bash +sim organizations invitations revoke [options] +``` + +Revoke Organization Invitation (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Get organization + +```bash +sim organizations get +``` + +Get Organization (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `organizationId` | Yes | Organization identifier. | + + + +## List organization members + +```bash +sim organizations members list [options] +``` + +List Organization Members (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against member name or email. | +| `--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`, `email`, `joinedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +## Remove organization member + +```bash +sim organizations members remove [options] +``` + +Remove Organization Member (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the organization member. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Update organization member + +```bash +sim organizations members update [options] +``` + +Update Organization Member (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the organization member. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--role ` | Yes | New organization role. Ownership transfers use a separate operation. Accepted values: `member`, `admin`. | + + + +## List organizations + +```bash +sim organizations list [options] +``` + +List Organizations (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the organization name. | +| `--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`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +## List organization workspaces + +```bash +sim organizations workspaces [options] +``` + +List Organization Workspaces (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the workspace name. | +| `--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`, `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + diff --git a/apps/docs/content/docs/cli/permission-groups.mdx b/apps/docs/content/docs/cli/permission-groups.mdx index 886849480e3..95a5cecb700 100644 --- a/apps/docs/content/docs/cli/permission-groups.mdx +++ b/apps/docs/content/docs/cli/permission-groups.mdx @@ -23,9 +23,9 @@ Add Permission Group Member (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | -| `--user-id ` | Yes | Existing organization member to add. | +| `--user ` | Yes | Existing organization member to add. | @@ -43,11 +43,10 @@ Bulk Add Permission Group Members (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | -| `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | -| `--add-all-organization-members` | No | Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds. | -| `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | +| `--user ` | No | User IDs to add; cannot be combined with --all-members (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--all-members` | No | Add every current organization member; cannot be combined with --user. | @@ -65,7 +64,7 @@ List Permission Group Members (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `assignedAt`, `userId`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -76,7 +75,7 @@ List Permission Group Members (OAuth login or personal API key required) ## Remove permission group member ```bash -sim permission-groups members remove [options] +sim permission-groups members remove [options] ``` Remove Permission Group Member (OAuth login or personal API key required) @@ -87,7 +86,7 @@ Remove Permission Group Member (OAuth login or personal API key required) | Argument | Required | Description | | --- | --- | --- | -| `memberId` | Yes | Membership assignment identifier returned by List Permission Group Members. | +| `userId` | Yes | User identifier of the member to remove. | @@ -97,7 +96,7 @@ Remove Permission Group Member (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | | `-y, --yes` | Yes | Confirm this operation. | @@ -117,13 +116,13 @@ Create Permission Group (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--name ` | Yes | Group name, unique within the organization. | | `--description ` | No | Optional group description. | -| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | -| `--no-is-default` | No | Send --is-default as false. | -| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | +| `--config ` | No | Permission restrictions to set. Omitted keys use the default permission configuration. (JSON, or @path / @- to read a file or stdin). | +| `--default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-default` | No | Send --default as false. | +| `--workspace-ids ` | No | Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group. (JSON, or @path / @- to read a file or stdin). | @@ -151,7 +150,7 @@ Delete Permission Group (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `-y, --yes` | Yes | Confirm this operation. | @@ -180,7 +179,7 @@ Get Permission Group (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | @@ -198,7 +197,7 @@ List Permission Groups (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--search ` | No | Case-insensitive substring match against the group name. | | `--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`. | @@ -206,28 +205,6 @@ List Permission Groups (OAuth login or personal API key required) -## List permission group workspaces - -```bash -sim permission-groups workspaces [options] -``` - -List Permission Group Workspaces (OAuth login or personal API key required) - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | -| `--search ` | No | Case-insensitive substring match against the workspace name. | -| `--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`, `id`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | - - - ## Update permission group ```bash @@ -252,12 +229,12 @@ Update Permission Group (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--name ` | No | Group name, unique within the organization. | | `--description ` | No | Group description. Null or an empty string clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | | `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | -| `--no-is-default` | No | Send --is-default as false. | +| `--default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-default` | No | Send --default as false. | | `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index a9c2cfe4119..7ece46c6d2d 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2996,6 +2996,278 @@ Show what this API supports and which limits apply sim meta status ``` +## sim organizations + +### sim organizations invitations create + +Create Organization Invitation (OAuth login or personal API key required) + +```bash +sim organizations invitations create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--email ` | Yes | Email address of the person to invite. | +| `--role ` | No | Organization role to offer. Defaults to member; grants no workspace-specific permissions. Accepted values: `member`, `admin`. | + + + +### sim organizations invitations get + +Get Organization Invitation (OAuth login or personal API key required) + +```bash +sim organizations invitations get [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations invitations list + +List Organization Invitations (OAuth login or personal API key required) + +```bash +sim organizations invitations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the invitee email. | +| `--status ` | No | Filter by current invitation status. Omit to include all statuses. Accepted values: `pending`, `accepted`, `rejected`, `cancelled`, `expired`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `email`, `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim organizations invitations resend + +Resend Organization Invitation (OAuth login or personal API key required) + +```bash +sim organizations invitations resend [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations invitations revoke + +Revoke Organization Invitation (OAuth login or personal API key required) + +```bash +sim organizations invitations revoke [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim organizations get + +Get Organization (OAuth login or personal API key required) + +```bash +sim organizations get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `organizationId` | Yes | Organization identifier. | + + + +### sim organizations members list + +List Organization Members (OAuth login or personal API key required) + +```bash +sim organizations members list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against member name or email. | +| `--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`, `email`, `joinedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +### sim organizations members remove + +Remove Organization Member (OAuth login or personal API key required) + +```bash +sim organizations members remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the organization member. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim organizations members update + +Update Organization Member (OAuth login or personal API key required) + +```bash +sim organizations members update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the organization member. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--role ` | Yes | New organization role. Ownership transfers use a separate operation. Accepted values: `member`, `admin`. | + + + +### sim organizations list + +List Organizations (OAuth login or personal API key required) + +```bash +sim organizations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the organization name. | +| `--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`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + +### sim organizations workspaces + +List Organization Workspaces (OAuth login or personal API key required) + +```bash +sim organizations workspaces [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the workspace name. | +| `--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`, `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + + + ## sim permission-groups Also spelled `sim permission-group`. @@ -3014,9 +3286,9 @@ sim permission-groups members add [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | -| `--user-id ` | Yes | Existing organization member to add. | +| `--user ` | Yes | Existing organization member to add. | @@ -3034,11 +3306,10 @@ sim permission-groups members batch-add [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | -| `--user-ids ` | No | Organization member identifiers. Existing group members are skipped; users outside the organization are ignored. (JSON, or @path / @- to read a file or stdin). | -| `--add-all-organization-members` | No | Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds. | -| `--no-add-all-organization-members` | No | Send --add-all-organization-members as false. | +| `--user ` | No | User IDs to add; cannot be combined with --all-members (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--all-members` | No | Add every current organization member; cannot be combined with --user. | @@ -3056,7 +3327,7 @@ sim permission-groups members list [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `assignedAt`, `userId`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -3069,7 +3340,7 @@ sim permission-groups members list [options] Remove Permission Group Member (OAuth login or personal API key required) ```bash -sim permission-groups members remove [options] +sim permission-groups members remove [options] ``` **Arguments** @@ -3078,7 +3349,7 @@ sim permission-groups members remove [options] | Argument | Required | Description | | --- | --- | --- | -| `memberId` | Yes | Membership assignment identifier returned by List Permission Group Members. | +| `userId` | Yes | User identifier of the member to remove. | @@ -3088,7 +3359,7 @@ sim permission-groups members remove [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--group ` | Yes | Permission group identifier. | | `-y, --yes` | Yes | Confirm this operation. | @@ -3108,13 +3379,13 @@ sim permission-groups create [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--name ` | Yes | Group name, unique within the organization. | | `--description ` | No | Optional group description. | -| `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | -| `--no-is-default` | No | Send --is-default as false. | -| `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | +| `--config ` | No | Permission restrictions to set. Omitted keys use the default permission configuration. (JSON, or @path / @- to read a file or stdin). | +| `--default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-default` | No | Send --default as false. | +| `--workspace-ids ` | No | Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group. (JSON, or @path / @- to read a file or stdin). | @@ -3142,7 +3413,7 @@ sim permission-groups delete [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `-y, --yes` | Yes | Confirm this operation. | @@ -3171,7 +3442,7 @@ sim permission-groups get [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | @@ -3189,7 +3460,7 @@ sim permission-groups list [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--search ` | No | Case-insensitive substring match against the group name. | | `--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`. | @@ -3197,28 +3468,6 @@ sim permission-groups list [options] -### sim permission-groups workspaces - -List Permission Group Workspaces (OAuth login or personal API key required) - -```bash -sim permission-groups workspaces [options] -``` - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | -| `--search ` | No | Case-insensitive substring match against the workspace name. | -| `--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`, `id`. | -| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | - - - ### sim permission-groups update Update Permission Group (OAuth login or personal API key required) @@ -3243,12 +3492,12 @@ sim permission-groups update [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | Yes | Organization that owns the permission group. | +| `--organization ` | Yes | Organization identifier. | | `--name ` | No | Group name, unique within the organization. | | `--description ` | No | Group description. Null or an empty string clears it; omission leaves it unchanged. (--description null sends the word, not JSON null). | | `--config ` | No | Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether the group is the organization default. Only one group can be the default. | -| `--no-is-default` | No | Send --is-default as false. | +| `--default` | No | Whether the group is the organization default. Only one group can be the default. | +| `--no-default` | No | Send --default as false. | | `--workspace-ids ` | No | Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive. (JSON, or @path / @- to read a file or stdin). | @@ -4808,8 +5057,8 @@ sim tables views update [options] | `--name ` | No | Replacement saved-view display name. | | `--config ` | No | Complete replacement saved-view configuration. (JSON, or @path / @- to read a file or stdin). | | `--config-patch ` | No | Saved-view configuration fields to shallow-merge. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether to promote this view to the table default. | -| `--no-is-default` | No | Send --is-default as false. | +| `--default` | No | Whether to promote this view to the table default. | +| `--no-default` | No | Send --default as false. | diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx index b1290e4af50..7cf0b265b3f 100644 --- a/apps/docs/content/docs/cli/tables.mdx +++ b/apps/docs/content/docs/cli/tables.mdx @@ -1012,8 +1012,8 @@ sim tables views update [options] | `--name ` | No | Replacement saved-view display name. | | `--config ` | No | Complete replacement saved-view configuration. (JSON, or @path / @- to read a file or stdin). | | `--config-patch ` | No | Saved-view configuration fields to shallow-merge. (JSON, or @path / @- to read a file or stdin). | -| `--is-default` | No | Whether to promote this view to the table default. | -| `--no-is-default` | No | Send --is-default as false. | +| `--default` | No | Whether to promote this view to the table default. | +| `--no-default` | No | Send --default as false. | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 3ce08b0208a..14be34561fe 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Organizations", + "description": "Discover organizations and manage their members and invitations." + }, { "name": "Permission Groups", "description": "Manage organization permission groups, their restrictions, and membership." @@ -5428,11 +5432,11 @@ } } }, - "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members/{memberId}": { + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members/{userId}": { "delete": { "operationId": "removePermissionGroupMember", "summary": "Remove Permission Group Member", - "description": "Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Remove a member by user identifier. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "permission_groups.members.remove", "x-oauth-scope": "api:write", "tags": ["Permission Groups"], @@ -5460,14 +5464,14 @@ } }, { - "name": "memberId", + "name": "userId", "in": "path", "required": true, - "description": "Membership assignment identifier returned by List Permission Group Members.", + "description": "User identifier of the member to remove.", "schema": { "type": "string", "minLength": 1, - "description": "Membership assignment identifier returned by List Permission Group Members." + "description": "User identifier of the member to remove." } } ], @@ -5618,33 +5622,22 @@ } } }, - "/api/v2/organizations/{organizationId}/permission-groups/workspaces": { + "/api/v2/organizations": { "get": { - "operationId": "listPermissionGroupWorkspaces", - "summary": "List Permission Group Workspaces", - "description": "List organization workspaces available for permission-group scope selection with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "permission_groups.workspaces.list", + "operationId": "listOrganizations", + "summary": "List Organizations", + "description": "List organizations the acting user belongs to. Organizations that disallow the calling credential are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.list", "x-oauth-scope": "api:read", - "tags": ["Permission Groups"], + "tags": ["Organizations"], "parameters": [ - { - "name": "organizationId", - "in": "path", - "required": true, - "description": "Organization that owns the permission groups.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Organization that owns the permission groups." - } - }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the workspace name.", + "description": "Case-insensitive substring match against the organization name.", "schema": { - "description": "Case-insensitive substring match against the workspace name.", + "description": "Case-insensitive substring match against the organization name.", "type": "string", "minLength": 1, "maxLength": 200 @@ -5659,7 +5652,7 @@ "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", "id"] + "enum": ["name", "createdAt"] } }, { @@ -5678,10 +5671,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum organization workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum organizations to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum organization workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum organizations to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -5701,7 +5694,7 @@ ], "responses": { "200": { - "description": "List Permission Group Workspaces result.", + "description": "List Organizations result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -5716,7 +5709,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListPermissionGroupWorkspacesResponse" + "$ref": "#/components/schemas/ListOrganizationsResponse" } } } @@ -5744,1797 +5737,1375 @@ } } } - } - }, - "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." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "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/organizations/{organizationId}": { + "get": { + "operationId": "getOrganization", + "summary": "Get Organization", + "description": "Get organization metadata and the acting user’s organization role. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.read", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "description": "Organization identifier." } } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" + ], + "responses": { + "200": { + "description": "Get Organization result.", + "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" } - } - } - } - }, - "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" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationResponse" } } } + }, + "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": { + } + }, + "/api/v2/organizations/{organizationId}/workspaces": { + "get": { + "operationId": "listOrganizationWorkspaces", + "summary": "List Organization Workspaces", + "description": "List active workspaces owned by the organization. Requires organization administrator access; does not require Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.workspaces.list", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "type": "string", + "minLength": 1, + "description": "Organization identifier." } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the workspace name.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "The request conflicts with the current state of the resource" - } + "description": "Case-insensitive substring match against the workspace name.", + "type": "string", + "minLength": 1, + "maxLength": 200 } - } - } - }, - "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": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "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", "id"] } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", "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" - } - } + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "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 } - } - } - }, - "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": { + }, + { + "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": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } + "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 } } - } - } - }, - "schemas": { - "V2ActionableForbiddenDetails": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" - } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." + "responses": { + "200": { + "description": "List Organization Workspaces result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "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." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationWorkspacesResponse" + } + } } - } - ] - }, - "V2Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." }, - "name": { - "type": "string", - "description": "Workspace display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "color": { - "type": "string", - "description": "Workspace color as a hexadecimal color value." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "logoUrl": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workspace logo URL, or null when none is configured." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "memberCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of effective members, including inherited organization administrators." + "404": { + "$ref": "#/components/responses/NotFound" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was created." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was last updated." - } - }, - "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], - "additionalProperties": false, - "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." + "500": { + "$ref": "#/components/responses/InternalError" }, - "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 credential.", - "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": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Workspace" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workspace response", - "description": "Public metadata for one workspace.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/members": { + "get": { + "operationId": "listOrganizationMembers", + "summary": "List Organization Members", + "description": "List organization members by name or email. Ordinary members must have access to the member directory; organization administrators retain access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.members.list", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ { - "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" + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." } - } - ] - }, - "V2WorkspaceMember": { - "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": "Member email address and public member identifier." }, - "name": { - "type": "string", - "description": "Member display name." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against member name or email.", + "schema": { + "description": "Case-insensitive substring match against member name or email.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "image": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member profile image URL, or null when absent." + { + "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", "email", "joinedAt"] + } }, - "role": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Effective role in the workspace." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "isExternal": { - "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum members to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum members to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "joinedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when access was granted." + { + "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 + } } - }, - "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], - "additionalProperties": false, - "title": "Workspace member", - "description": "An effective workspace member and their public access role." - }, - "ListWorkspaceMembersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2WorkspaceMember" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + ], + "responses": { + "200": { + "description": "List Organization Members result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 workspace members response", - "description": "A cursor-paginated page of effective workspace members.", - "examples": [ - { - "data": [ - { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "role": "admin", - "isExternal": false, - "joinedAt": "2026-01-15T10:30:00.000Z" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationMembersResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2McpServer": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique server identifier derived from the workspace and endpoint URL." - }, - "name": { - "type": "string", - "description": "Server display name." + } }, - "description": { - "description": "Optional server description.", - "type": "string" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "transport": { - "default": "streamable-http", - "description": "Transport used to communicate with the server.", - "type": "string", - "enum": ["streamable-http"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "authType": { - "description": "Authentication method used by the server.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "description": "Server endpoint URL.", - "type": "string" + "404": { + "$ref": "#/components/responses/NotFound" }, - "timeout": { - "description": "Per-request timeout in milliseconds.", - "type": "number" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "retries": { - "description": "Number of retries attempted per request.", - "type": "number" + "500": { + "$ref": "#/components/responses/InternalError" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." - }, - "connectionStatus": { - "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", - "type": "string", - "enum": ["connected", "disconnected", "error"] + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/members/{userId}": { + "patch": { + "operationId": "updateOrganizationMember", + "summary": "Update Organization Member", + "description": "Change a member’s organization role. Requires organization administrator access. The owner’s role and memberships managed by an identity provider cannot be changed here. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.members.update", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } }, - "lastError": { - "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", - "anyOf": [ - { - "type": "string" + { + "name": "userId", + "in": "path", + "required": true, + "description": "User identifier of the organization member.", + "schema": { + "type": "string", + "minLength": 1, + "description": "User identifier of the organization member." + } + } + ], + "requestBody": { + "required": true, + "description": "Update Organization Member input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationMemberBody" + } + } + } + }, + "responses": { + "200": { + "description": "Update Organization Member result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "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/UpdateOrganizationMemberResponse" + } + } + } }, - "toolCount": { - "description": "Number of tools discovered on the server.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastToolsRefresh": { - "description": "ISO 8601 timestamp of the most recent tool-list refresh.", - "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))$" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", - "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))$" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "description": "ISO 8601 timestamp when the server was registered.", - "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))$" + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "description": "ISO 8601 timestamp when the server was last updated.", - "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))$" + "409": { + "$ref": "#/components/responses/Conflict" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier, when configured.", - "type": "string" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "hasHeaders": { - "type": "boolean", - "description": "Whether any request headers are configured." + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "headerNames": { - "type": "array", - "items": { - "type": "string", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": [ - "id", - "name", - "transport", - "enabled", - "createdAt", - "updatedAt", - "hasHeaders", - "headerNames", - "hasOauthClientSecret" - ], - "additionalProperties": false, - "title": "MCP server", - "description": "Public MCP server configuration without write-only credential values." + } }, - "ListMcpServersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpServer" - }, - "description": "Items in the current page." + "delete": { + "operationId": "removeOrganizationMember", + "summary": "Remove Organization Member", + "description": "Remove a member and revoke their access to organization workspaces. Administrators may remove members; members may remove themselves. The organization owner cannot be removed. Owned organization resources are reassigned and the departing member’s sessions end. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.members.remove", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "name": "userId", + "in": "path", + "required": true, + "description": "User identifier of the organization member.", + "schema": { + "type": "string", + "minLength": 1, + "description": "User identifier of the organization member." + } + } + ], + "responses": { + "200": { + "description": "Remove Organization Member result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 MCP servers response", - "description": "MCP servers registered in the workspace.", - "examples": [ - { - "data": [ - { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveOrganizationMemberResponse" + } } - ], - "nextCursor": null + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - ] - }, - "CreateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create MCP server response", - "description": "The registered MCP server without write-only credentials.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/invitations": { + "get": { + "operationId": "listOrganizationInvitations", + "summary": "List Organization Invitations", + "description": "List invitations owned by the organization, including invitations with workspace grants. Requires organization administrator access. Expired invitations are reported without modifying them. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.invitations.list", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "disconnected", - "lastError": null, - "toolCount": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." } - } - ] - }, - "CreateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to register the server." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the invitee email.", + "schema": { + "description": "Case-insensitive substring match against the invitee email.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by current invitation status. Omit to include all statuses.", + "schema": { + "description": "Filter by current invitation status. Omit to include all statuses.", + "type": "string", + "enum": ["pending", "accepted", "rejected", "cancelled", "expired"] + } }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + { + "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": ["email", "createdAt"] + } }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum invitations to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum invitations to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { + { + "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": "List Organization Invitations result.", + "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" + } }, - "additionalProperties": { - "type": "string", - "description": "Header value sent to the MCP server." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationInvitationsResponse" + } + } } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["workspaceId", "name", "url"], - "additionalProperties": false, - "title": "Create MCP server request", - "description": "Configuration for a new MCP server.", - "examples": [ + } + }, + "post": { + "operationId": "createOrganizationInvitation", + "summary": "Create Organization Invitation", + "description": "Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.invitations.create", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Docs server", - "url": "https://mcp.example.com/sse", - "authType": "headers", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." } } - ] - }, - "GetMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" + ], + "requestBody": { + "required": true, + "description": "Create Organization Invitation input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationInvitationBody" + } + } } }, - "required": ["data"], - "additionalProperties": false, - "title": "Get MCP server response", - "description": "One MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false - } - } - ] - }, - "UpdateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update MCP server response", - "description": "The updated MCP server.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": false, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "responses": { + "201": { + "description": "Create Organization Invitation result.", + "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/CreateOrganizationInvitationResponse" + } + } } - } - ] - }, - "UpdateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the MCP server." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "url": { - "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "403": { + "$ref": "#/components/responses/Forbidden" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { - "type": "string", - "description": "Header value sent to the MCP server." - } + "409": { + "$ref": "#/components/responses/Conflict" }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "500": { + "$ref": "#/components/responses/InternalError" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update MCP server request", - "description": "MCP server fields to change; omitted fields retain their stored values.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/invitations/{invitationId}": { + "get": { + "operationId": "getOrganizationInvitation", + "summary": "Get Organization Invitation", + "description": "Get an invitation owned by the organization. Requires organization administrator access. The response excludes the acceptance token. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.invitations.read", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "enabled": false - } - ] - }, - "V2McpServerDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted MCP server." + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the server was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete MCP server data", - "description": "MCP server deletion acknowledgement." - }, - "DeleteMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServerDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete MCP server response", - "description": "Acknowledgement that the MCP server was deleted.", - "examples": [ { - "data": { - "id": "mcp-3f7a9c21", - "deleted": true + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." } } - ] - }, - "V2McpTool": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tool name, as the MCP server reports it." - }, - "description": { - "description": "Tool description reported by the server.", - "type": "string" - }, - "inputSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "object", - "description": "JSON Schema type of the argument object. MCP requires `object`." + ], + "responses": { + "200": { + "description": "Get Organization Invitation result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "properties": { - "description": "Argument schemas keyed by argument name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Server-defined JSON Schema for one tool argument." - } + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "required": { - "description": "Names of the arguments the tool requires.", - "type": "array", - "items": { - "type": "string", - "description": "Name of a required argument." - } + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["type"], - "additionalProperties": { - "description": "Additional JSON Schema keyword reported by the server." - }, - "description": "JSON Schema for the tool's arguments, as reported by the server." - }, - "serverId": { - "type": "string", - "description": "Identifier of the MCP server exposing the tool." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationInvitationResponse" + } + } + } }, - "serverName": { - "type": "string", - "description": "Display name of the MCP server exposing the tool." - } - }, - "required": ["name", "inputSchema", "serverId", "serverName"], - "additionalProperties": false, - "title": "MCP tool", - "description": "A tool exposed by a registered MCP server." - }, - "ListMcpServerToolsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpTool" - }, - "description": "Items in the current page." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP server tools response", - "description": "Tools exposed by the MCP server.", - "examples": [ - { - "data": [ - { - "name": "search_docs", - "description": "Search the internal documentation", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search terms" - } - }, - "required": ["query"] - }, - "serverId": "mcp-3f7a9c21", - "serverName": "Docs server" - } - ], - "nextCursor": null - } - ] - }, - "V2SkillSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "404": { + "$ref": "#/components/responses/NotFound" }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "500": { + "$ref": "#/components/responses/InternalError" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Skill summary", - "description": "Public summary metadata for a workspace or built-in skill." + } }, - "ListSkillsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2SkillSummary" - }, - "description": "Items in the current page." + "delete": { + "operationId": "revokeOrganizationInvitation", + "summary": "Revoke Organization Invitation", + "description": "Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "invitations.revoke", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." + } + } + ], + "responses": { + "200": { + "description": "Revoke Organization Invitation result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 skills response", - "description": "Skill summaries available in the workspace.", - "examples": [ - { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeOrganizationInvitationResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2Skill": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + } }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." - }, - "content": { - "type": "string", - "description": "Skill body containing the instructions given to the agent." - } - }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], - "additionalProperties": false, - "title": "Skill", - "description": "A workspace or built-in skill including its instruction body." - }, - "CreateSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create skill response", - "description": "The created skill including its content.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." - } - } - ] - }, - "CreateSkillRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the skill." + "409": { + "$ref": "#/components/responses/Conflict" }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", - "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "One-line summary of when the skill applies." + "500": { + "$ref": "#/components/responses/InternalError" }, - "content": { - "type": "string", - "minLength": 1, - "maxLength": 50000, - "description": "Skill body containing the instructions given to the agent." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["workspaceId", "name", "description", "content"], - "additionalProperties": false, - "title": "Create skill request", - "description": "Definition of a new skill.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/invitations/{invitationId}/resend": { + "post": { + "operationId": "resendOrganizationInvitation", + "summary": "Resend Organization Invitation", + "description": "Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "invitations.resend", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "refund-policy", - "description": "How support should handle refund requests", - "content": "# Refund policy\n\nAlways check the order date first." - } - ] - }, - "GetSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get skill response", - "description": "One skill including its full content.", - "examples": [ + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." } } - ] - }, - "UpdateSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + ], + "requestBody": { + "required": false, + "description": "Resend Organization Invitation input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendOrganizationInvitationBody" + } + } } }, - "required": ["data"], - "additionalProperties": false, - "title": "Update skill response", - "description": "The updated skill including its full content.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "Updated refund guidance", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "responses": { + "200": { + "description": "Resend Organization Invitation result.", + "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/ResendOrganizationInvitationResponse" + } + } } - } - ] - }, - "UpdateSkillRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the skill." }, - "name": { - "description": "New kebab-case skill name.", - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "New one-line summary of when the skill applies.", - "type": "string", - "minLength": 1, - "maxLength": 1024 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "content": { - "description": "Replacement skill body.", - "type": "string", - "minLength": 1, - "maxLength": 50000 - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update skill request", - "description": "Skill fields to change; at least one editable field is required.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "description": "Updated refund guidance" - } - ] - }, - "V2SkillDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted skill." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the skill was deleted." + "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" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete skill data", - "description": "Skill deletion acknowledgement." - }, - "DeleteSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete skill response", - "description": "Acknowledgement that the skill was deleted.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + } + } + } + }, + "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." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "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" + } } } - ] + } }, - "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" + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" } - ], - "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" + "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" }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } } - ], - "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 + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" } - ], - "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 + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current state of the resource" + } } } - ] + } }, - "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." + "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" } }, - "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" + "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" + } + } + } } - ] + } }, - "V2SkillEditorDeleteData": { + "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": { + "V2ActionableForbiddenDetails": { "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." + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" } }, - "required": ["email", "revoked"], - "additionalProperties": false, - "title": "Revoke skill editor data", - "description": "Skill editor revocation acknowledgement." + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." }, - "RevokeSkillEditorResponse": { + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SkillEditorDeleteData" + "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 context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." } }, - "required": ["data"], + "required": ["error"], "additionalProperties": false, - "title": "Revoke skill editor response", - "description": "Acknowledgement that the explicit editor grant was revoked.", + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", "examples": [ { - "data": { - "email": "jane@example.com", - "revoked": true + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." } } ] }, - "V2CustomTool": { + "V2Workspace": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique custom tool identifier." + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, - "title": { + "name": { "type": "string", - "description": "Display title, unique within the workspace." + "description": "Workspace display name." }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "color": { + "type": "string", + "description": "Workspace color as a hexadecimal color value." + }, + "logoUrl": { + "anyOf": [ + { + "type": "string" }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + ], + "description": "Workspace logo URL, or null when none is configured." }, - "code": { - "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of effective members, including inherited organization administrators." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was created." + "description": "ISO 8601 timestamp when the workspace was created." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was last updated." + "description": "ISO 8601 timestamp when the workspace was last updated." } }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + "title": "Workspace", + "description": "Public metadata for an accessible workspace." }, - "ListCustomToolsResponse": { + "ListWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2Workspace" }, "description": "Items in the current page." }, @@ -7552,32 +7123,18 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "List workspaces response", + "description": "Public metadata for workspaces available to the credential.", "examples": [ { "data": [ { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", + "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" } ], @@ -7585,463 +7142,168 @@ } ] }, - "CreateCustomToolResponse": { + "GetWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2Workspace" } }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Get workspace response", + "description": "Public metadata for one workspace.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", + "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" } } ] }, - "CreateCustomToolRequest": { + "V2WorkspaceMember": { "type": "object", "properties": { - "workspaceId": { + "email": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Member email address and public member identifier." }, - "title": { + "name": { "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "description": "Member display name." }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "image": { + "anyOf": [ + { + "type": "string" }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + ], + "description": "Member profile image URL, or null when absent." }, - "code": { + "role": { "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "enum": ["admin", "write", "read"], + "description": "Effective role in the workspace." + }, + "isExternal": { + "type": "boolean", + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when access was granted." } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" - } - ] + "title": "Workspace member", + "description": "An effective workspace member and their public access role." }, - "GetCustomToolResponse": { + "ListWorkspaceMembersResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the custom tool." - }, - "title": { - "description": "New display title for the tool.", - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "array", + "items": { + "$ref": "#/components/schemas/V2WorkspaceMember" + }, + "description": "Items in the current page." }, - "schema": { - "description": "Replacement function declaration.", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + { + "type": "null" } - }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - } - }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["workspaceId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", + "title": "List workspace members response", + "description": "A cursor-paginated page of effective workspace members.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "admin", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null } ] }, - "V2CustomToolDeleteData": { + "V2McpServer": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted custom tool." + "description": "Unique server identifier derived from the workspace and endpoint URL." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." - }, - "DeleteCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true - } - } - ] - }, - "V2Sandbox": { - "type": "object", - "properties": { - "id": { + "name": { "type": "string", - "description": "Unique sandbox identifier." + "description": "Server display name." }, - "name": { + "description": { + "description": "Optional server description.", + "type": "string" + }, + "transport": { + "default": "streamable-http", + "description": "Transport used to communicate with the server.", "type": "string", - "description": "Display name, unique within the workspace." + "enum": ["streamable-http"] }, - "language": { + "authType": { + "description": "Authentication method used by the server.", "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + "enum": ["none", "headers", "oauth"] }, - "dependencies": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Package specifiers installed into the sandbox, one per entry." + "url": { + "description": "Server endpoint URL.", + "type": "string" }, - "cliTools": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - }, - "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates." + "timeout": { + "description": "Per-request timeout in milliseconds.", + "type": "number" }, - "systemPackages": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Debian packages installed into the sandbox, one per entry." + "retries": { + "description": "Number of retries attempted per request.", + "type": "number" }, - "buildStatus": { - "anyOf": [ - { - "type": "string", - "enum": ["pending", "building", "ready", "failed"] - }, - { - "type": "null" - } - ], - "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + "enabled": { + "type": "boolean", + "description": "Whether the server tools are available to workflows." }, - "errorCode": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Classified build failure code, or `null`." + "connectionStatus": { + "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", + "type": "string", + "enum": ["connected", "disconnected", "error"] }, - "errorMessage": { + "lastError": { + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", "anyOf": [ { "type": "string" @@ -8049,72 +7311,79 @@ { "type": "null" } - ], - "description": "Human-readable build failure summary, or `null`." + ] }, - "errorDetail": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Tail of the installer log for a failed build, or `null`." + "toolCount": { + "description": "Number of tools discovered on the server.", + "type": "number" }, - "builtAt": { - "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": "ISO 8601 timestamp when the current image finished building, or `null`." + "lastToolsRefresh": { + "description": "ISO 8601 timestamp of the most recent tool-list refresh.", + "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))$" + }, + "lastConnected": { + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", + "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))$" }, "createdAt": { + "description": "ISO 8601 timestamp when the server was registered.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the sandbox was created." + "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))$" }, "updatedAt": { + "description": "ISO 8601 timestamp when the server was last updated.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the sandbox was last updated." + "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))$" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier, when configured.", + "type": "string" + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured." + }, + "headerNames": { + "type": "array", + "items": { + "type": "string", + "description": "Configured header name." + }, + "description": "Names of configured request headers. Header values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored. The value is never returned." } }, "required": [ "id", "name", - "language", - "dependencies", - "cliTools", - "systemPackages", - "buildStatus", - "errorCode", - "errorMessage", - "errorDetail", - "builtAt", + "transport", + "enabled", "createdAt", - "updatedAt" + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" ], "additionalProperties": false, - "title": "Sandbox", - "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." + "title": "MCP server", + "description": "Public MCP server configuration without write-only credential values." }, - "ListSandboxesResponse": { + "ListMcpServersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2McpServer" }, "description": "Items in the current page." }, @@ -8132,434 +7401,470 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List sandboxes response", - "description": "Sandboxes defined in the workspace.", + "title": "List MCP servers response", + "description": "MCP servers registered in the workspace.", "examples": [ { "data": [ { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "ready", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": "2026-06-20T14:05:40.000Z", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } ], "nextCursor": null } ] }, - "CreateSandboxResponse": { + "CreateMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create sandbox response", - "description": "The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.", + "title": "Create MCP server response", + "description": "The registered MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "pending", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": null, + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "disconnected", + "lastError": null, + "toolCount": 0, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "CreateSandboxRequest": { + "CreateMcpServerRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the sandbox." + "description": "Workspace in which to register the server." }, "name": { "type": "string", "minLength": 1, - "maxLength": 64, - "description": "Display name, unique within the workspace; 1 to 64 characters." + "maxLength": 255, + "description": "Server display name." }, - "language": { + "description": { + "description": "Optional server description.", "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." - }, - "dependencies": { - "default": [], - "description": "Package specifiers installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "maxLength": 2000 }, - "cliTools": { - "default": [], - "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", - "maxItems": 10, - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - } + "transport": { + "description": "Transport protocol. Defaults to `streamable-http` on creation.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] }, - "systemPackages": { - "default": [], - "description": "Debian packages installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, + "type": "object", + "propertyNames": { "type": "string", - "maxLength": 2000 + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." } + }, + "timeout": { + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Defaults to 3 on creation.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] } }, - "required": ["workspaceId", "name", "language"], + "required": ["workspaceId", "name", "url"], "additionalProperties": false, - "title": "Create sandbox request", - "description": "Name, language, and dependency set of a new sandbox.", + "title": "Create MCP server request", + "description": "Configuration for a new MCP server.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "systemPackages": ["graphviz"] + "name": "Docs server", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } } ] }, - "GetSandboxResponse": { + "GetMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Get sandbox response", - "description": "One sandbox.", + "title": "Get MCP server response", + "description": "One MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "ready", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": "2026-06-20T14:05:40.000Z", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "UpdateSandboxResponse": { + "UpdateMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Update sandbox response", - "description": "The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.", + "title": "Update MCP server response", + "description": "The updated MCP server.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests", "pyarrow"], - "cliTools": [], - "systemPackages": ["graphviz"], - "buildStatus": "pending", - "errorCode": null, - "errorMessage": null, - "errorDetail": null, - "builtAt": null, + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "UpdateSandboxRequest": { + "UpdateMcpServerRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the sandbox." + "description": "Workspace that owns the MCP server." }, "name": { - "description": "New display name, unique within the workspace; 1 to 64 characters.", "type": "string", "minLength": 1, - "maxLength": 64 + "maxLength": 255, + "description": "Server display name." }, - "language": { - "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", + "description": { + "description": "Optional server description.", "type": "string", - "enum": ["javascript", "python"] + "maxLength": 2000 }, - "dependencies": { - "description": "Replacement package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { + "transport": { + "description": "Transport protocol. Defaults to `streamable-http` on creation.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, + "type": "object", + "propertyNames": { "type": "string", - "maxLength": 2000 + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." } }, - "cliTools": { - "description": "Replacement managed CLI list; replaces the whole list.", - "maxItems": 10, - "type": "array", - "items": { - "type": "string", - "enum": [ - "google-cloud-cli@577.0.0-r1", - "aws-cli@2.36.15-r1", - "azure-cli@2.89.0-r1", - "doctl@1.166.0-r1", - "github-cli@2.97.0-r1", - "gitlab-cli@1.111.0-r1", - "kubectl@1.36.3-r1", - "helm@4.2.3-r1", - "kustomize@5.8.1-r1", - "argocd@3.4.6-r1", - "terraform@1.15.8-r1", - "pulumi@3.255.0-r1", - "supabase-cli@2.111.0-r1", - "firebase-cli@15.25.1-r1", - "flyctl@0.4.78-r1", - "railway-cli@5.30.4-r1", - "stripe-cli@1.45.0-r1", - "duckdb@1.5.5-r1", - "rclone@1.75.0-r1", - "restic@0.19.1-r1", - "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", - "mongosh@2.9.2-r1", - "sops@3.13.3-r1", - "age@1.3.1-r1" - ] - } + "timeout": { + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 }, - "systemPackages": { - "description": "Replacement Debian package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "retries": { + "description": "Number of retries per request. Defaults to 3 on creation.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] } }, "required": ["workspaceId"], "additionalProperties": false, - "title": "Update sandbox request", - "description": "Sandbox fields to change; at least one editable field is required.", + "title": "Update MCP server request", + "description": "MCP server fields to change; omitted fields retain their stored values.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + "enabled": false } ] }, - "V2SandboxDeleteData": { + "V2McpServerDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted sandbox." + "description": "Identifier of the deleted MCP server." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the sandbox was deleted." + "description": "Whether the server was deleted." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete sandbox data", - "description": "Sandbox deletion acknowledgement." + "title": "Delete MCP server data", + "description": "MCP server deletion acknowledgement." }, - "DeleteSandboxResponse": { + "DeleteMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SandboxDeleteData" + "$ref": "#/components/schemas/V2McpServerDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete sandbox response", - "description": "Acknowledgement that the sandbox was deleted.", + "title": "Delete MCP server response", + "description": "Acknowledgement that the MCP server was deleted.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", + "id": "mcp-3f7a9c21", "deleted": true } } ] }, - "V2Credential": { + "V2McpTool": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique credential identifier." - }, - "type": { - "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." - }, - "displayName": { + "name": { "type": "string", - "description": "Credential display name." + "description": "Tool name, as the MCP server reports it." }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "description": "Tool description reported by the server.", + "type": "string" }, - "providerId": { - "anyOf": [ - { - "type": "string" + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "object", + "description": "JSON Schema type of the argument object. MCP requires `object`." }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." - }, - "accountId": { - "anyOf": [ - { - "type": "string" + "properties": { + "description": "Argument schemas keyed by argument name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Server-defined JSON Schema for one tool argument." + } }, - { - "type": "null" + "required": { + "description": "Names of the arguments the tool requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required argument." + } } - ], - "description": "Linked account identifier for OAuth credentials." - }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." - }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + }, + "required": ["type"], + "additionalProperties": { + "description": "Additional JSON Schema keyword reported by the server." + }, + "description": "JSON Schema for the tool's arguments, as reported by the server." }, - "createdAt": { + "serverId": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was created." + "description": "Identifier of the MCP server exposing the tool." }, - "updatedAt": { + "serverName": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was last updated." + "description": "Display name of the MCP server exposing the tool." } }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], + "required": ["name", "inputSchema", "serverId", "serverName"], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "MCP tool", + "description": "A tool exposed by a registered MCP server." }, - "ListCredentialsResponse": { + "ListMcpServerToolsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2McpTool" }, "description": "Items in the current page." }, @@ -8572,43 +7877,1707 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", "examples": [ { "data": [ { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "name": "search_docs", + "description": "Search the internal documentation", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms" + } + }, + "required": ["query"] + }, + "serverId": "mcp-3f7a9c21", + "serverName": "Docs server" } ], "nextCursor": null } ] }, - "V2CredentialProvider": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth", - "description": "Browser-based OAuth connection method." - }, + "V2SkillSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + } + }, + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Skill summary", + "description": "Public summary metadata for a workspace or built-in skill." + }, + "ListSkillsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2SkillSummary" + }, + "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 skills response", + "description": "Skill summaries available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "V2Skill": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + }, + "content": { + "type": "string", + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], + "additionalProperties": false, + "title": "Skill", + "description": "A workspace or built-in skill including its instruction body." + }, + "CreateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create skill response", + "description": "The created skill including its content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "CreateSkillRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the skill." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["workspaceId", "name", "description", "content"], + "additionalProperties": false, + "title": "Create skill request", + "description": "Definition of a new skill.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + ] + }, + "GetSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get skill response", + "description": "One skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update skill response", + "description": "The updated skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + }, + "name": { + "description": "New kebab-case skill name.", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "description": "New one-line summary of when the skill applies.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "description": "Replacement skill body.", + "type": "string", + "minLength": 1, + "maxLength": 50000 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update skill request", + "description": "Skill fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + ] + }, + "V2SkillDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted skill." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the skill was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete skill data", + "description": "Skill deletion acknowledgement." + }, + "DeleteSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete skill response", + "description": "Acknowledgement that the skill was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "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": { + "id": { + "type": "string", + "description": "Unique custom tool identifier." + }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "description": "Tool implementation executed in the sandboxed function runtime." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was last updated." + } + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }" + } + ] + }, + "GetCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get custom tool response", + "description": "One custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update custom tool response", + "description": "The updated custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the custom tool." + }, + "title": { + "description": "New display title for the tool.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "schema": { + "description": "Replacement function declaration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + } + }, + "code": { + "description": "Replacement tool implementation.", + "type": "string", + "maxLength": 100000 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + ] + }, + "V2CustomToolDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted custom tool." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." + }, + "DeleteCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomToolDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "V2Sandbox": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique sandbox identifier." + }, + "name": { + "type": "string", + "description": "Display name, unique within the workspace." + }, + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Package specifiers installed into the sandbox, one per entry." + }, + "cliTools": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + }, + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates." + }, + "systemPackages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Debian packages installed into the sandbox, one per entry." + }, + "buildStatus": { + "anyOf": [ + { + "type": "string", + "enum": ["pending", "building", "ready", "failed"] + }, + { + "type": "null" + } + ], + "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + }, + "errorCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Classified build failure code, or `null`." + }, + "errorMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Human-readable build failure summary, or `null`." + }, + "errorDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Tail of the installer log for a failed build, or `null`." + }, + "builtAt": { + "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": "ISO 8601 timestamp when the current image finished building, or `null`." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the sandbox was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the sandbox was last updated." + } + }, + "required": [ + "id", + "name", + "language", + "dependencies", + "cliTools", + "systemPackages", + "buildStatus", + "errorCode", + "errorMessage", + "errorDetail", + "builtAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Sandbox", + "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." + }, + "ListSandboxesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Sandbox" + }, + "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 sandboxes response", + "description": "Sandboxes defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create sandbox response", + "description": "The created sandbox. `buildStatus` is `pending` while an image builds and `null` where nothing is built.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateSandboxRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the sandbox." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Display name, unique within the workspace; 1 to 64 characters." + }, + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + }, + "dependencies": { + "default": [], + "description": "Package specifiers installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "cliTools": { + "default": [], + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } + }, + "systemPackages": { + "default": [], + "description": "Debian packages installed into the sandbox, one per entry.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + } + }, + "required": ["workspaceId", "name", "language"], + "additionalProperties": false, + "title": "Create sandbox request", + "description": "Name, language, and dependency set of a new sandbox.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "systemPackages": ["graphviz"] + } + ] + }, + "GetSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get sandbox response", + "description": "One sandbox.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "ready", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": "2026-06-20T14:05:40.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Sandbox" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update sandbox response", + "description": "The updated sandbox. `buildStatus` is `pending` while an image rebuilds and `null` where nothing is built.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "data-tools", + "language": "python", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"], + "cliTools": [], + "systemPackages": ["graphviz"], + "buildStatus": "pending", + "errorCode": null, + "errorMessage": null, + "errorDetail": null, + "builtAt": null, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateSandboxRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the sandbox." + }, + "name": { + "description": "New display name, unique within the workspace; 1 to 64 characters.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "language": { + "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", + "type": "string", + "enum": ["javascript", "python"] + }, + "dependencies": { + "description": "Replacement package list; replaces the whole list.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "cliTools": { + "description": "Replacement managed CLI list; replaces the whole list.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "enum": [ + "google-cloud-cli@577.0.0-r1", + "aws-cli@2.36.15-r1", + "azure-cli@2.89.0-r1", + "doctl@1.166.0-r1", + "github-cli@2.97.0-r1", + "gitlab-cli@1.111.0-r1", + "kubectl@1.36.3-r1", + "helm@4.2.3-r1", + "kustomize@5.8.1-r1", + "argocd@3.4.6-r1", + "terraform@1.15.8-r1", + "pulumi@3.255.0-r1", + "supabase-cli@2.111.0-r1", + "firebase-cli@15.25.1-r1", + "flyctl@0.4.78-r1", + "railway-cli@5.30.4-r1", + "stripe-cli@1.45.0-r1", + "duckdb@1.5.5-r1", + "rclone@1.75.0-r1", + "restic@0.19.1-r1", + "minio-mc@RELEASE.2025-08-13T08-35-41Z-r1", + "mongosh@2.9.2-r1", + "sops@3.13.3-r1", + "age@1.3.1-r1" + ] + } + }, + "systemPackages": { + "description": "Replacement Debian package list; replaces the whole list.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update sandbox request", + "description": "Sandbox fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + } + ] + }, + "V2SandboxDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted sandbox." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the sandbox was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete sandbox data", + "description": "Sandbox deletion acknowledgement." + }, + "DeleteSandboxResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SandboxDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete sandbox response", + "description": "Acknowledgement that the sandbox was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "V2Credential": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique credential identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional credential description." + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was last updated." + } + }, + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." + }, + "ListCredentialsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", + "examples": [ + { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "V2CredentialProvider": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, "serviceId": { "type": "string", "minLength": 1, @@ -13083,14 +14052,14 @@ } }, "additionalProperties": false, - "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + "description": "Permission restrictions to set. Omitted keys use the default permission configuration." }, "isDefault": { "description": "Whether the group is the organization default. Only one group can be the default.", "type": "boolean" }, "workspaceIds": { - "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", + "description": "Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.", "maxItems": 500, "type": "array", "items": { @@ -13482,159 +14451,774 @@ "type": "boolean", "description": "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace." }, - "disableCliAccess": { - "type": "boolean", - "description": "Prevent approving a CLI login or using Sim CLI OAuth tokens for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group." + "disableCliAccess": { + "type": "boolean", + "description": "Prevent approving a CLI login or using Sim CLI OAuth tokens for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group." + }, + "disableWebhookTriggers": { + "type": "boolean", + "description": "Prevent making a workflow reachable from an inbound webhook." + }, + "disableToolAutoApproval": { + "type": "boolean", + "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + }, + "hideSandboxesTab": { + "type": "boolean", + "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + }, + "disableOAuthAppAccess": { + "type": "boolean", + "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + }, + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." + } + }, + "additionalProperties": false, + "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + }, + "isDefault": { + "description": "Whether the group is the organization default. Only one group can be the default.", + "type": "boolean" + }, + "workspaceIds": { + "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "title": "Update Permission Group request", + "description": "Update Permission Group inputs.", + "examples": [ + { + "description": "Restricted workspace access" + } + ] + }, + "V2PermissionGroupDeletion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Deleted permission group identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the group was permanently deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Permission group deletion", + "description": "Acknowledges permanent group deletion." + }, + "DeletePermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete Permission Group response", + "description": "Delete Permission Group result.", + "examples": [ + { + "data": { + "id": "group-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupMember": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "userId": { + "type": "string", + "description": "Organization member assigned to the group." + }, + "assignedAt": { + "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": "When the member was assigned." + }, + "userName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member display name." + }, + "userEmail": { + "anyOf": [ + { + "type": "string" }, - "disableWebhookTriggers": { - "type": "boolean", - "description": "Prevent making a workflow reachable from an inbound webhook." + { + "type": "null" + } + ], + "description": "Member email address." + }, + "userImage": { + "anyOf": [ + { + "type": "string" }, - "disableToolAutoApproval": { - "type": "boolean", - "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + { + "type": "null" + } + ], + "description": "Member avatar URL." + } + }, + "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "additionalProperties": false, + "title": "Permission group member", + "description": "An explicit permission-group membership assignment." + }, + "ListPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2PermissionGroupMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "hideSandboxesTab": { - "type": "boolean", - "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + { + "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 Permission Group Members response", + "description": "List Permission Group Members result.", + "examples": [ + { + "data": [ + { + "id": "assignment-123", + "userId": "user-123", + "assignedAt": "2026-06-01T09:00:00.000Z", + "userName": "Example Member", + "userEmail": "member@example.com", + "userImage": null + } + ], + "nextCursor": null + } + ] + }, + "V2PermissionGroupAssignment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "permissionGroupId": { + "type": "string", + "description": "Group receiving the member." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the group." + }, + "userId": { + "type": "string", + "description": "User assigned to the group." + }, + "assignedBy": { + "type": "string", + "description": "User who made the assignment." + }, + "assignedAt": { + "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": "When the assignment was created." + } + }, + "required": [ + "id", + "permissionGroupId", + "organizationId", + "userId", + "assignedBy", + "assignedAt" + ], + "additionalProperties": false, + "title": "Permission group assignment", + "description": "The newly created membership assignment." + }, + "AddPermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupAssignment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Add Permission Group Member response", + "description": "Add Permission Group Member result.", + "examples": [ + { + "data": { + "id": "assignment-123", + "permissionGroupId": "group-123", + "organizationId": "org-123", + "userId": "user-123", + "assignedBy": "admin-123", + "assignedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "AddPermissionGroupMemberRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "Existing organization member to add." + } + }, + "required": ["userId"], + "additionalProperties": false, + "title": "Add Permission Group Member request", + "description": "Add Permission Group Member inputs.", + "examples": [ + { + "userId": "user-123" + } + ] + }, + "V2PermissionGroupMemberDeletion": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User whose membership assignment was removed." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the assignment was removed." + } + }, + "required": ["userId", "deleted"], + "additionalProperties": false, + "title": "Permission group member deletion", + "description": "Acknowledges membership removal." + }, + "RemovePermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Remove Permission Group Member response", + "description": "Remove Permission Group Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupBulkAdd": { + "type": "object", + "properties": { + "added": { + "type": "number", + "description": "Number of members added." + }, + "skipped": { + "type": "number", + "description": "Number of selected organization members already in the group." + } + }, + "required": ["added", "skipped"], + "additionalProperties": false, + "title": "Permission group bulk addition", + "description": "Counts of added and already assigned organization members." + }, + "BulkAddPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Bulk Add Permission Group Members response", + "description": "Bulk Add Permission Group Members result.", + "examples": [ + { + "data": { + "added": 1, + "skipped": 0 + } + } + ] + }, + "BulkAddPermissionGroupMembersRequest": { + "type": "object", + "properties": { + "userIds": { + "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "addAllOrganizationMembers": { + "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Bulk Add Permission Group Members request", + "description": "Bulk Add Permission Group Members inputs.", + "examples": [ + { + "userIds": ["user-123"] + } + ] + }, + "V2Organization": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Organization identifier." + }, + "name": { + "type": "string", + "description": "Organization display name." + }, + "slug": { + "type": "string", + "description": "Organization slug, or null when unset." + }, + "logo": { + "anyOf": [ + { + "type": "string" }, - "disableOAuthAppAccess": { - "type": "boolean", - "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + { + "type": "null" + } + ], + "description": "Organization logo URL, or null when unset." + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "The acting user’s role in this organization." + }, + "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": "When the organization was created." + } + }, + "required": ["id", "name", "slug", "logo", "role", "createdAt"], + "additionalProperties": false, + "title": "Organization", + "description": "An organization the acting user belongs to." + }, + "ListOrganizationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Organization" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "disableKnowledgeBaseExport": { - "type": "boolean", - "description": "Prevent downloading a whole knowledge base as an archive." + { + "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 Organizations response", + "description": "List Organizations result.", + "examples": [ + { + "data": [ + { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "GetOrganizationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Organization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Organization response", + "description": "Get Organization result.", + "examples": [ + { + "data": { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "V2OrganizationWorkspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Workspace identifier." + }, + "name": { + "type": "string", + "description": "Workspace display name." + } + }, + "required": ["id", "name"], + "additionalProperties": false, + "title": "Organization workspace", + "description": "A workspace owned by the organization." + }, + "ListOrganizationWorkspacesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationWorkspace" }, - "additionalProperties": false, - "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + "description": "Items in the current page." }, - "isDefault": { - "description": "Whether the group is the organization default. Only one group can be the default.", - "type": "boolean" + "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 Organization Workspaces response", + "description": "List Organization Workspaces result.", + "examples": [ + { + "data": [ + { + "id": "workspace-123", + "name": "Engineering" + } + ], + "nextCursor": null + } + ] + }, + "V2OrganizationMember": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier; use this identifier to update or remove the member." }, - "workspaceIds": { - "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", - "maxItems": 500, + "name": { + "type": "string", + "description": "Member display name." + }, + "email": { + "type": "string", + "description": "Member email address." + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "Organization role; separate from workspace permissions." + }, + "joinedAt": { + "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": "When the user joined the organization." + } + }, + "required": ["userId", "name", "email", "role", "joinedAt"], + "additionalProperties": false, + "title": "Organization member", + "description": "An organization membership identified by user ID." + }, + "ListOrganizationMembersResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string", - "minLength": 1 + "$ref": "#/components/schemas/V2OrganizationMember" + }, + "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 Organization Members response", + "description": "List Organization Members result.", + "examples": [ + { + "data": [ + { + "userId": "user-123", + "name": "Example Member", + "email": "member@example.com", + "role": "member", + "joinedAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "UpdateOrganizationMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationMember" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update Organization Member response", + "description": "Update Organization Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "name": "Example Member", + "email": "member@example.com", + "role": "admin", + "joinedAt": "2026-06-01T09:00:00.000Z" } } + ] + }, + "UpdateOrganizationMemberBody": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["member", "admin"], + "description": "New organization role. Ownership transfers use a separate operation." + } }, + "required": ["role"], "additionalProperties": false, - "title": "Update Permission Group request", - "description": "Update Permission Group inputs.", + "title": "Update Organization Member body", + "description": "Update Organization Member input.", "examples": [ { - "description": "Restricted workspace access" + "role": "admin" } ] }, - "V2PermissionGroupDeletion": { + "V2OrganizationMemberDeletion": { "type": "object", "properties": { - "id": { + "userId": { "type": "string", - "description": "Deleted permission group identifier." + "description": "User removed from the organization." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the group was permanently deleted." + "description": "Whether membership and organization workspace access were removed." } }, - "required": ["id", "deleted"], + "required": ["userId", "deleted"], "additionalProperties": false, - "title": "Permission group deletion", - "description": "Acknowledges permanent group deletion." + "title": "Organization member removal", + "description": "Acknowledges removal of an organization member." }, - "DeletePermissionGroupResponse": { + "RemoveOrganizationMemberResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupDeletion" + "$ref": "#/components/schemas/V2OrganizationMemberDeletion" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete Permission Group response", - "description": "Delete Permission Group result.", + "title": "Remove Organization Member response", + "description": "Remove Organization Member result.", "examples": [ { "data": { - "id": "group-123", + "userId": "user-123", "deleted": true } } ] }, - "V2PermissionGroupMember": { + "V2OrganizationInvitation": { "type": "object", "properties": { "id": { "type": "string", - "description": "Membership assignment identifier." + "description": "Invitation identifier." }, - "userId": { + "organizationId": { "type": "string", - "description": "Organization member assigned to the group." + "description": "Organization that owns the invitation." }, - "assignedAt": { + "email": { "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": "When the member was assigned." + "description": "Email address of the invitee." }, - "userName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member display name." + "role": { + "type": "string", + "enum": ["member", "admin"], + "description": "Organization role offered to an internal invitee." }, - "userEmail": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member email address." + "kind": { + "type": "string", + "enum": ["organization", "workspace"], + "description": "Whether the invitation originated from organization or workspace administration." }, - "userImage": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member avatar URL." + "membershipIntent": { + "type": "string", + "enum": ["internal", "external"], + "description": "Whether acceptance joins the organization or grants workspace access only." + }, + "status": { + "type": "string", + "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], + "description": "Current invitation status; elapsed pending invitations are reported as expired." + }, + "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": "When the invitation was created." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the invitation expires." } }, - "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "required": [ + "id", + "organizationId", + "email", + "role", + "kind", + "membershipIntent", + "status", + "createdAt", + "expiresAt" + ], "additionalProperties": false, - "title": "Permission group member", - "description": "An explicit permission-group membership assignment." + "title": "Organization invitation", + "description": "Invitation metadata without its acceptance token." }, - "ListPermissionGroupMembersResponse": { + "ListOrganizationInvitationsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2PermissionGroupMember" + "$ref": "#/components/schemas/V2OrganizationInvitation" }, "description": "Items in the current page." }, @@ -13652,266 +15236,185 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Permission Group Members response", - "description": "List Permission Group Members result.", + "title": "List Organization Invitations response", + "description": "List Organization Invitations result.", "examples": [ { "data": [ { - "id": "assignment-123", - "userId": "user-123", - "assignedAt": "2026-06-01T09:00:00.000Z", - "userName": "Example Member", - "userEmail": "member@example.com", - "userImage": null + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" } ], "nextCursor": null } ] }, - "V2PermissionGroupAssignment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Membership assignment identifier." - }, - "permissionGroupId": { - "type": "string", - "description": "Group receiving the member." - }, - "organizationId": { - "type": "string", - "description": "Organization that owns the group." - }, - "userId": { - "type": "string", - "description": "User assigned to the group." - }, - "assignedBy": { - "type": "string", - "description": "User who made the assignment." - }, - "assignedAt": { - "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": "When the assignment was created." - } - }, - "required": [ - "id", - "permissionGroupId", - "organizationId", - "userId", - "assignedBy", - "assignedAt" - ], - "additionalProperties": false, - "title": "Permission group assignment", - "description": "The newly created membership assignment." - }, - "AddPermissionGroupMemberResponse": { + "CreateOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupAssignment" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Add Permission Group Member response", - "description": "Add Permission Group Member result.", + "title": "Create Organization Invitation response", + "description": "Create Organization Invitation result.", "examples": [ { "data": { - "id": "assignment-123", - "permissionGroupId": "group-123", + "id": "invitation-123", "organizationId": "org-123", - "userId": "user-123", - "assignedBy": "admin-123", - "assignedAt": "2026-06-01T09:00:00.000Z" + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" } } ] }, - "AddPermissionGroupMemberRequest": { + "CreateOrganizationInvitationBody": { "type": "object", "properties": { - "userId": { + "email": { "type": "string", "minLength": 1, - "description": "Existing organization member to add." + "maxLength": 254, + "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 person to invite." + }, + "role": { + "default": "member", + "description": "Organization role to offer. Defaults to member; grants no workspace-specific permissions.", + "type": "string", + "enum": ["member", "admin"] } }, - "required": ["userId"], + "required": ["email"], "additionalProperties": false, - "title": "Add Permission Group Member request", - "description": "Add Permission Group Member inputs.", + "title": "Create Organization Invitation body", + "description": "Create Organization Invitation input.", "examples": [ { - "userId": "user-123" + "email": "member@example.com", + "role": "member" } ] }, - "V2PermissionGroupMemberDeletion": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Removed membership assignment identifier." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the assignment was removed." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Permission group member deletion", - "description": "Acknowledges membership removal." - }, - "RemovePermissionGroupMemberResponse": { + "GetOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Remove Permission Group Member response", - "description": "Remove Permission Group Member result.", + "title": "Get Organization Invitation response", + "description": "Get Organization Invitation result.", "examples": [ { "data": { - "id": "assignment-123", - "deleted": true + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" } } ] }, - "V2PermissionGroupBulkAdd": { - "type": "object", - "properties": { - "added": { - "type": "number", - "description": "Number of members added." - }, - "skipped": { - "type": "number", - "description": "Number of selected organization members already in the group." - } - }, - "required": ["added", "skipped"], - "additionalProperties": false, - "title": "Permission group bulk addition", - "description": "Counts of added and already assigned organization members." - }, - "BulkAddPermissionGroupMembersResponse": { + "ResendOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Bulk Add Permission Group Members response", - "description": "Bulk Add Permission Group Members result.", + "title": "Resend Organization Invitation response", + "description": "Resend Organization Invitation result.", "examples": [ { "data": { - "added": 1, - "skipped": 0 + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" } } ] }, - "BulkAddPermissionGroupMembersRequest": { + "ResendOrganizationInvitationBody": { + "default": {}, + "title": "Resend Organization Invitation body", + "description": "Resend Organization Invitation input.", + "examples": [{}], "type": "object", - "properties": { - "userIds": { - "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", - "minItems": 1, - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "addAllOrganizationMembers": { - "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", - "type": "boolean" - } - }, - "additionalProperties": false, - "title": "Bulk Add Permission Group Members request", - "description": "Bulk Add Permission Group Members inputs.", - "examples": [ - { - "userIds": ["user-123"] - } - ] + "properties": {}, + "additionalProperties": false }, - "V2PermissionGroupWorkspace": { + "V2OrganizationInvitationRevocation": { "type": "object", "properties": { "id": { "type": "string", - "description": "Workspace identifier." + "description": "Revoked invitation identifier." }, - "name": { + "status": { "type": "string", - "description": "Workspace display name." + "const": "cancelled", + "description": "Revocation cancels the invitation and prevents acceptance." } }, - "required": ["id", "name"], + "required": ["id", "status"], "additionalProperties": false, - "title": "Permission group workspace", - "description": "An organization workspace available for group targeting." + "title": "Organization invitation revocation", + "description": "Acknowledges cancellation of a pending invitation." }, - "ListPermissionGroupWorkspacesResponse": { + "RevokeOrganizationInvitationResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2PermissionGroupWorkspace" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitationRevocation" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List Permission Group Workspaces response", - "description": "List Permission Group Workspaces result.", + "title": "Revoke Organization Invitation response", + "description": "Revoke Organization Invitation result.", "examples": [ { - "data": [ - { - "id": "workspace-123", - "name": "Engineering" - } - ], - "nextCursor": null + "data": { + "id": "invitation-123", + "status": "cancelled" + } } ] } diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts index 4418ace293b..f6ab18a40d0 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest } from '@sim/testing' +import { member, user } from '@sim/db/schema' +import { authMockFns, createMockRequest, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -71,6 +72,10 @@ vi.mock('@/lib/workspaces/policy', () => ({ getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), +})) + import { POST } from '@/app/api/invitations/[id]/resend/route' const mockGetSession = authMockFns.mockGetSession @@ -94,6 +99,9 @@ const workspaceInvitation = { email: 'invitee@example.com', role: 'member', token: 'token-1', + expiresAt: new Date('2099-01-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), organizationId: 'organization-1', membershipIntent: 'internal', grants: [{ workspaceId: 'workspace-1', permission: 'read' }], @@ -107,7 +115,13 @@ const workspaceInvitation = { describe('POST /api/invitations/[id]/resend', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'admin@example.com' } }) + resetDbChainMock() + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(user, [{ name: 'Admin', email: 'admin@example.com' }]) + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'admin@example.com' }, + session: { id: 'session-1' }, + }) mockGetInvitationById.mockResolvedValue(workspaceInvitation) mockResolveInvitationAdmissionOrganizationId.mockResolvedValue('organization-1') mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) @@ -148,7 +162,7 @@ describe('POST /api/invitations/[id]/resend', () => { const response = await callResend() expect(response.status).toBe(403) - expect(await response.json()).toEqual({ + expect(await response.json()).toMatchObject({ error: "Sending invitations is not available under your organization's permission group", details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) @@ -161,6 +175,8 @@ describe('POST /api/invitations/[id]/resend', () => { * someone with no admin standing to hear it. */ it('checks admin standing before the permission group', async () => { + resetDbChainMock() + queueTableRows(member, [{ role: 'member' }]) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) mockHasWorkspaceAdminAccess.mockResolvedValue(false) @@ -275,4 +291,22 @@ describe('POST /api/invitations/[id]/resend', () => { organizationId: 'organization-1', }) }) + it.each(['pending', 'expired'])( + 'rejects an expired %s invitation consistently', + async (status) => { + mockGetInvitationById.mockResolvedValue({ + ...workspaceInvitation, + status, + expiresAt: new Date('2000-01-01'), + }) + expect((await callResend()).status).toBe(409) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + } + ) + + it('leaves the token unchanged when delivery fails', async () => { + mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'Delivery unavailable' }) + expect((await callResend()).status).toBe(502) + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 2d8bb7f511b..7ada3ddac7e 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -1,223 +1,22 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { invitationParamsSchema } from '@/lib/api/contracts/invitations' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getOrganizationSubscription } from '@/lib/billing/core/billing' -import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' -import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' -import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getInvitationById, resolveInvitationAdmissionOrganizationId } from '@/lib/invitations/core' +import { resendInvitationContract } from '@/lib/api/contracts/invitations' import { - persistInvitationResend, - prepareInvitationResend, - sendInvitationEmail, -} from '@/lib/invitations/send' -import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' -import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' -import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' -import { - InvitationsNotAllowedError, - validateInvitationsAllowed, -} from '@/ee/access-control/utils/permission-check' - -const logger = createLogger('InvitationResendAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const parsedParams = invitationParamsSchema.safeParse(await params) - if (!parsedParams.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parsedParams.error) }, - { status: 400 } - ) - } - const { id } = parsedParams.data - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const inv = await getInvitationById(id) - if (!inv) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - if (inv.status !== 'pending') { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) - } - - let canResend = false - if (inv.organizationId) { - canResend = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) - } - if (!canResend && inv.grants.length > 0) { - const adminChecks = await Promise.all( - inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) - ) - canResend = adminChecks.some(Boolean) - } - if (!canResend) { - return NextResponse.json( - { error: 'Only an organization or workspace admin can resend this invitation' }, - { status: 403 } - ) - } - - /** - * permission-group-enforced: invitations.send — a resend is a send. - * - * It re-delivers a working link and pushes `expiresAt` forward, so an - * organization that has withheld invitations would otherwise still admit - * new people: every pending invitation stays revivable indefinitely by - * anyone who can reach this route, and each resend mints a fresh token. - * The invitee has not joined yet — resend is the step that gets them in — - * which is why this is not the webhook active-config carve-out, where the - * reachability already exists and the edit only adjusts it. - * - * Each granted workspace resolves the group governing the caller there, - * exactly as creation does. The organization scope is checked *as well*, - * not instead, whenever the invitation ADMITS TO an organization — which - * is not the same question as its `kind`. A workspace-kind invitation - * whose granted workspace belongs to an organization joins the invitee to - * that organization exactly as an organization-kind one does, so keying - * this on the kind left every organization-backed workspace invitation - * performing an ungated organization admission. `resolveInvitationAdmission- - * OrganizationId` answers it from acceptance's own derivation: the live - * organization of the granted workspace for a workspace-kind invitation, - * the stamped one otherwise, and nobody at all when the intent is external - * or the stamped organization refuses the escalation — the three cases - * where acceptance creates no member row. Gating only the grants would let - * an explicit workspace group that permits invitations carry a member into - * an organization whose default group withholds them. - * - * Run after the admin check above, for the reason - * `resolveWorkspaceInvitationContext` records — the refusal names an - * organization setting, so it must not reach someone with no admin reach. - */ - try { - const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) - if (admissionOrganizationId) { - await validateInvitationsAllowed(session.user.id, { - organizationId: admissionOrganizationId, - }) - } - for (const grant of inv.grants) { - await validateInvitationsAllowed(session.user.id, { workspaceId: grant.workspaceId }) - } - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - logger.warn('Invitation resend blocked by permission group', { invitationId: id }) - return capabilityRefusalResponse('invitations.send') - } - throw error - } - - for (const grant of inv.grants) { - const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) - if (!workspaceDetails) { - return NextResponse.json( - { error: 'Invitation references a workspace that no longer exists' }, - { status: 409 } - ) - } - const policy = await getWorkspaceInvitePolicy(workspaceDetails) - if (!policy.allowed) { - return NextResponse.json( - { - error: policy.reason ?? 'Invites are no longer allowed on this workspace', - upgradeRequired: policy.upgradeRequired, - }, - { status: 403 } - ) - } - } - - if (inv.kind === 'organization' && inv.grants.length === 0 && inv.organizationId) { - const orgSubscription = await getOrganizationSubscription(inv.organizationId) - const orgOnTeamOrEnterprise = - !!orgSubscription && - hasUsableSubscriptionStatus(orgSubscription.status) && - (isTeam(orgSubscription.plan) || isEnterprise(orgSubscription.plan)) - if (!orgOnTeamOrEnterprise) { - return NextResponse.json( - { - error: 'Invites are no longer allowed on this organization', - upgradeRequired: true, - }, - { status: 403 } - ) - } - } - - const { tokenForEmail, nextToken, nextExpiresAt } = await prepareInvitationResend({ - invitationId: id, - rotateToken: true, - currentToken: inv.token, - }) - - const [inviterRow] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const emailResult = await sendInvitationEmail({ - invitationId: inv.id, - token: tokenForEmail, - kind: inv.kind, - email: inv.email, - inviterName: inviterRow?.name || inviterRow?.email || 'A user', - organizationId: inv.organizationId, - organizationRole: (inv.role as 'admin' | 'member') || 'member', - grants: inv.grants.map((grant) => ({ - workspaceId: grant.workspaceId, - permission: grant.permission, - })), - }) - - if (!emailResult.success) { - return NextResponse.json( - { error: emailResult.error || 'Failed to send invitation email' }, - { status: 502 } - ) - } - - await persistInvitationResend({ invitationId: id, nextToken, nextExpiresAt }) - - recordAudit({ - workspaceId: inv.grants[0]?.workspaceId ?? null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_RESENT - : AuditAction.ORG_INVITATION_RESENT, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, - description: `Resent ${inv.kind} invitation to ${inv.email}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: inv.role, - kind: inv.kind, - membershipIntent: inv.membershipIntent, - }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to resend invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { resendInvitation } from '@/lib/invitations/application/mutations' +import { invitationOperations } from '@/lib/invitations/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: resendInvitationContract, + auth: internalSessionAuth, + operation: invitationOperations.resend, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing invitation management admission', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params }) => ({ invitationId: params.id }), + useCase: resendInvitation, + present: () => ({ success: true }), +}) diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 4e4dd46b9f2..1dd511f7aea 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -3,20 +3,26 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import { type NextRequest, NextResponse } from 'next/server' import { - cancelInvitationQuerySchema, + cancelInvitationContract, getInvitationContract, - invitationParamsSchema, updateInvitationContract, } from '@/lib/api/contracts/invitations' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { revokeInvitation } from '@/lib/invitations/application/mutations' +import { invitationOperations } from '@/lib/invitations/application/operations' import { getInvitationById, getInvitationJoinPreview, isInvitationExpired, - revokeInvitationAsAdmin, updateInvitation, } from '@/lib/invitations/core' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' @@ -192,133 +198,15 @@ export const PATCH = withRouteHandler( } ) -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const parsedParams = invitationParamsSchema.safeParse(await params) - if (!parsedParams.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parsedParams.error) }, - { status: 400 } - ) - } - const { id } = parsedParams.data - const parsedQuery = cancelInvitationQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!parsedQuery.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parsedQuery.error, 'Invalid query parameters') }, - { status: 400 } - ) - } - const scopedWorkspaceId = parsedQuery.data.workspaceId - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const result = await revokeInvitationAsAdmin({ - actorId: session.user.id, - invitationId: id, - workspaceId: scopedWorkspaceId, - }) - if (!result.success) { - if (result.kind === 'not-found') { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - if (result.kind === 'not-pending') { - return NextResponse.json( - { error: 'Can only cancel pending invitations' }, - { status: 400 } - ) - } - if (result.kind === 'grant-not-found') { - return NextResponse.json( - { error: 'Invitation does not grant access to that workspace' }, - { status: 400 } - ) - } - if (result.kind === 'scoped-forbidden') { - return NextResponse.json( - { error: 'You need admin permissions on that workspace to revoke its invitation' }, - { status: 403 } - ) - } - if (result.kind === 'whole-forbidden') { - return NextResponse.json( - { - error: result.spansMultipleWorkspaces - ? 'This invitation spans several workspaces. Revoke it from a workspace you administer, or ask an organization admin.' - : 'Only an organization or workspace admin can cancel this invitation', - }, - { status: 403 } - ) - } - return NextResponse.json({ error: 'Invitation not cancellable' }, { status: 400 }) - } - - /** - * Scoped revocation: an admin of this one workspace may withdraw its own - * grant. Authority over the invitation's other workspaces is not implied, - * so only that grant is removed. - */ - if (scopedWorkspaceId) { - recordAudit({ - workspaceId: scopedWorkspaceId, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: AuditAction.INVITATION_REVOKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: scopedWorkspaceId, - description: `Revoked ${result.invitation.email}'s pending invitation to this workspace`, - metadata: { - invitationId: id, - targetEmail: result.invitation.email, - workspaceId: scopedWorkspaceId, - invitationCancelled: result.invitationCancelled, - }, - request, - }) - - return NextResponse.json({ - success: true, - invitationCancelled: result.invitationCancelled, - }) - } - - const inv = result.invitation - recordAudit({ - workspaceId: inv.grants[0]?.workspaceId ?? null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_REVOKED - : AuditAction.ORG_INVITATION_REVOKED, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? id, - description: `Cancelled ${inv.kind} invitation for ${inv.email}`, - metadata: { - invitationId: id, - targetEmail: inv.email, - targetRole: inv.role, - kind: inv.kind, - }, - request, - }) - - return NextResponse.json({ - success: true, - invitationCancelled: result.invitationCancelled, - }) - } catch (error) { - logger.error('Failed to cancel invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: cancelInvitationContract, + auth: internalSessionAuth, + operation: invitationOperations.revoke, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing invitation management admission', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ invitationId: params.id, workspaceId: query.workspaceId }), + useCase: revokeInvitation, + present: (result) => ({ success: true, invitationCancelled: result.invitationCancelled }), +}) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 50e265f208f..63165da87e8 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -1,29 +1,29 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' import { member, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization' -import { parseRequest } from '@/lib/api/server' +import { + removeOrganizationMemberContract, + updateOrganizationMemberRoleContract, +} from '@/lib/api/contracts/organization' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' -import { - acquireOrganizationUserMutationLocks, - removeExternalUserFromOrganizationWorkspaces, - removeUserFromOrganization, - WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR, -} from '@/lib/billing/organizations/membership' -import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' -import { ForbiddenOperationError } from '@/lib/core/application' -import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isRetryableTransactionError } from '@/lib/db/transaction' -import { changeMemberRoleTx } from '@/lib/organizations/members/lifecycle' +import { + removeOrganizationMember, + updateOrganizationMember, +} from '@/lib/organizations/application/members' +import { organizationOperations } from '@/lib/organizations/application/operations' import { captureServerEvent } from '@/lib/posthog/server' -import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('OrganizationMemberAPI') @@ -147,409 +147,84 @@ export const GET = withRouteHandler( } ) -/** - * PUT /api/organizations/[id]/members/[memberId] - * Update organization member role - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateOrganizationMemberRoleContract, request, context) - if (!parsed.success) return parsed.response - - const { id: organizationId, memberId } = parsed.data.params - const { role } = parsed.data.body - - const userMember = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (userMember.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - if (!isOrgAdminRole(userMember[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const targetMember = await db - .select({ - id: member.id, - role: member.role, - userId: member.userId, - email: user.email, - name: user.name, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .limit(1) - - if (targetMember.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - if (targetMember[0].role === 'owner') { - return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 }) - } - - if (role === 'owner') { - return NextResponse.json( - { - error: - 'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.', - }, - { status: 400 } - ) - } - - /** - * The member is re-read under the organization's mutation lock, so a - * concurrent promotion to owner — or a directory provisioning this very - * member — cannot slip between the checks and the write. When the - * organization has made its identity provider the source of truth, a role - * set here is reverted by the next sync; refusing says so. - */ - const roleChange = await db.transaction(async (tx) => { - await acquireOrganizationUserMutationLocks(tx, { - userId: memberId, - organizationIds: [organizationId], - }) - await assertMembershipNotScimManaged({ organizationId, userId: memberId, executor: tx }) - return changeMemberRoleTx(tx, { organizationId, userId: memberId, role }) - }) - - /** - * The audit row and analytics event fire whether or not the role actually - * moved, exactly as this route did before the write went through the - * shared primitive. Callers assert on those side effects. - */ - logger.info('Organization member role updated', { - organizationId, - memberId, - newRole: role, - updatedBy: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Changed role for member ${memberId} to ${role}`, - metadata: { - targetUserId: memberId, - targetEmail: targetMember[0].email ?? undefined, - targetName: targetMember[0].name ?? undefined, - changes: [{ field: 'role', from: targetMember[0].role, to: role }], - }, - request, - }) - - captureServerEvent( - session.user.id, - 'org_member_role_changed', - { organization_id: organizationId, new_role: role }, - { groups: { organization: organizationId } } - ) - - return NextResponse.json({ - success: true, - message: 'Member role updated successfully', - data: { - id: targetMember[0].id, - userId: targetMember[0].userId, - role: roleChange.changed ? roleChange.to : roleChange.role, - updatedBy: session.user.id, - }, - }) - } catch (error) { - if (error instanceof ForbiddenOperationError) { - return NextResponse.json( - { error: error.message, details: { code: error.detailCode } }, - { status: 403 } - ) - } - if (error instanceof OrchestrationError) { - return NextResponse.json( - { error: error.message }, - { status: statusForOrchestrationError(error.code) } - ) - } - /** The role change now serializes on the organization lock; a timeout is "retry", not a fault. */ - if (isRetryableTransactionError(error)) { - return NextResponse.json( - { error: 'The organization is busy; retry in a moment' }, - { status: 409 } - ) - } - - logger.error('Failed to update organization member role', { - organizationId: (await context.params).id, - memberId: (await context.params).memberId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -/** - * DELETE /api/organizations/[id]/members/[memberId] - * Remove member from organization - */ -export const DELETE = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; memberId: string }> } - ) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, memberId: targetUserId } = await params - - const userMember = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (userMember.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - const canRemoveMembers = - isOrgAdminRole(userMember[0].role) || session.user.id === targetUserId - - if (!canRemoveMembers) { - return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) - } - - const targetMember = await db - .select({ id: member.id, role: member.role, email: user.email, name: user.name }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId))) - .limit(1) - - if (targetMember.length === 0) { - const [targetUser] = await db - .select({ id: user.id, email: user.email, name: user.name }) - .from(user) - .where(eq(user.id, targetUserId)) - .limit(1) - - if (!targetUser) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - const externalResult = await removeExternalUserFromOrganizationWorkspaces({ - userId: targetUserId, - organizationId, - }) - - if (!externalResult.success) { - const error = externalResult.error || 'External workspace member not found' - const status = - error === 'External workspace member not found' - ? 404 - : error === 'User is an organization member' - ? 409 - : error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR - ? 400 - : 500 - - return NextResponse.json({ error }, { status }) - } - - logger.info('External workspace member removed from organization workspaces', { - organizationId, - removedMemberId: targetUserId, - removedBy: session.user.id, - workspaceAccessRevoked: externalResult.workspaceAccessRevoked, - permissionGroupsRevoked: externalResult.permissionGroupsRevoked, - credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, - pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_REMOVED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Removed external workspace member ${targetUserId} from organization`, - metadata: { - targetUserId, - targetEmail: targetUser.email ?? undefined, - targetName: targetUser.name ?? undefined, +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationMemberRoleContract, + auth: internalSessionAuth, + operation: organizationOperations.updateMember, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization member administration behavior.', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + userId: params.memberId, + role: body.role, + }), + useCase: updateOrganizationMember, + present: ({ member }, { principal }) => ({ + success: true, + message: 'Member role updated successfully', + data: { id: member.id, userId: member.userId, role: member.role, updatedBy: principal.userId }, + }), + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'org_member_role_changed', + { organization_id: input.organizationId, new_role: input.role }, + { groups: { organization: input.organizationId } } + ) + }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: removeOrganizationMemberContract, + auth: internalSessionAuth, + operation: organizationOperations.removeMember, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization member administration behavior.', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, userId: params.memberId }), + useCase: removeOrganizationMember, + present: (result) => ({ + success: true, + message: + result.membershipType === 'external' + ? 'External member removed successfully' + : result.wasSelfRemoval + ? 'You have left the organization' + : 'Member removed successfully', + data: { + removedMemberId: result.target.userId, + removedBy: result.removedBy, + removedAt: result.removedAt, + ...(result.membershipType === 'external' + ? { membershipType: 'external', - workspaceAccessRevoked: externalResult.workspaceAccessRevoked, - permissionGroupsRevoked: externalResult.permissionGroupsRevoked, - credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, - pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, - }, - request, - }) - - captureServerEvent( - session.user.id, - 'org_member_removed', - { organization_id: organizationId, is_self_removal: session.user.id === targetUserId }, - { groups: { organization: organizationId } } - ) - - return NextResponse.json({ - success: true, - message: 'External member removed successfully', - data: { - removedMemberId: targetUserId, - removedBy: session.user.id, - removedAt: new Date().toISOString(), - membershipType: 'external', - workspaceAccessRevoked: externalResult.workspaceAccessRevoked, - permissionGroupsRevoked: externalResult.permissionGroupsRevoked, - credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, - pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, - }, - }) - } - - const result = await removeUserFromOrganization({ - userId: targetUserId, - organizationId, - memberId: targetMember[0].id, - spareSessionToken: session.session.token, - }) - - if (!result.success) { - if (result.error === 'Cannot remove organization owner') { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - if (result.error === 'Member not found') { - return NextResponse.json({ error: result.error }, { status: 404 }) - } - if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - return NextResponse.json({ error: result.error }, { status: 500 }) - } - - let seatReduction: Awaited> | null = null + workspaceAccessRevoked: result.removal.workspaceAccessRevoked, + permissionGroupsRevoked: result.removal.permissionGroupsRevoked, + credentialMembershipsRevoked: result.removal.credentialMembershipsRevoked, + pendingInvitationsCancelled: result.removal.pendingInvitationsCancelled, + } + : { seatReduction: result.seatReduction }), + }, + }), + async onSuccess({ principal, input, result }) { + if (result.wasSelfRemoval) { try { - seatReduction = await reconcileOrganizationSeats({ - organizationId, - reason: 'member-removed', - actorId: session.user.id, + await setActiveOrganizationForCurrentSession(null) + } catch (error) { + logger.warn('Failed to clear active organization after self-removal', { + organizationId: input.organizationId, + error, }) - } catch (seatError) { - logger.error('Failed to reduce seats after member removal', { - organizationId, - removedMemberId: targetUserId, - removedBy: session.user.id, - error: seatError, - }) - seatReduction = { - changed: false, - reason: 'Failed to reduce seats after member removal', - } } - - if (session.user.id === targetUserId) { - try { - await setActiveOrganizationForCurrentSession(null) - } catch (clearError) { - logger.warn('Failed to clear active organization after self-removal', { - userId: session.user.id, - organizationId, - error: clearError, - }) - } - } - - logger.info('Organization member removed', { - organizationId, - removedMemberId: targetUserId, - removedBy: session.user.id, - wasSelfRemoval: session.user.id === targetUserId, - billingActions: result.billingActions, - seatReduction, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_REMOVED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: - session.user.id === targetUserId - ? 'Left the organization' - : `Removed member ${targetUserId} from organization`, - metadata: { - targetUserId, - targetEmail: targetMember[0].email ?? undefined, - targetName: targetMember[0].name ?? undefined, - wasSelfRemoval: session.user.id === targetUserId, - seatReduction, - }, - request, - }) - - captureServerEvent( - session.user.id, - 'org_member_removed', - { organization_id: organizationId, is_self_removal: session.user.id === targetUserId }, - { groups: { organization: organizationId } } - ) - - return NextResponse.json({ - success: true, - message: - session.user.id === targetUserId - ? 'You have left the organization' - : 'Member removed successfully', - data: { - removedMemberId: targetUserId, - removedBy: session.user.id, - removedAt: new Date().toISOString(), - seatReduction, - }, - }) - } catch (error) { - logger.error('Failed to remove organization member', { - organizationId: (await params).id, - memberId: (await params).memberId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - } -) + captureServerEvent( + principal.userId, + 'org_member_removed', + { organization_id: input.organizationId, is_self_removal: result.wasSelfRemoval }, + { groups: { organization: input.organizationId } } + ) + }, +}) diff --git a/apps/sim/app/api/organizations/[id]/members/route.test.ts b/apps/sim/app/api/organizations/[id]/members/route.test.ts index 5eab80f930b..248783cec4f 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.test.ts @@ -56,7 +56,10 @@ describe('GET /api/organizations/[id]/members', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) + mockGetSession.mockResolvedValue({ + ...createSession({ userId: 'user-reader' }), + session: { id: 'session-reader' }, + }) mockGetOrgPermissionConfig.mockResolvedValue(null) }) @@ -91,7 +94,7 @@ describe('GET /api/organizations/[id]/members', () => { const response = await request() expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ + await expect(response.json()).resolves.toMatchObject({ error: capabilityRefusal('organization.member_directory'), }) }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 858c33f74ae..1d7b7701de2 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -1,201 +1,64 @@ -import { db } from '@sim/db' -import { member, user, userStats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, count, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { - organizationMemberQuerySchema, - organizationParamsSchema, + listOrganizationMembersContract, + organizationMemberUsageSchema, } from '@/lib/api/contracts/organization' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { organizationRoleSchema } from '@/lib/api/contracts/primitives' import { - capabilityRefusal, - isOrganizationCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' - -const logger = createLogger('OrganizationMembersAPI') - -/** - * GET /api/organizations/[id]/members - * Get organization members with optional usage data - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = organizationParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - - const { id: organizationId } = paramsResult.data - const queryResult = organizationMemberQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') }, - { status: 400 } - ) - } - const { limit, offset } = queryResult.data - const includeUsage = queryResult.data.include === 'usage' - - // Verify user has access to this organization - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - const userRole = memberEntry[0].role - const hasAdminAccess = isOrgAdminRole(userRole) - - /** - * permission-group-enforced: organization.member_directory — an - * organization-scoped read with no workspace for the funnel to authorize. - * - * Admins and owners are exempt. This response is the only source for the - * team-management page and the seat-usage snapshot it renders, so - * withholding it from an admin would take away the page they would use to - * change the setting, and their seat management with it. - */ - if ( - !hasAdminAccess && - (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) - ) { - logger.warn('Organization member directory blocked by permission group', { - organizationId, - userId: session.user.id, - }) - return NextResponse.json( - { error: capabilityRefusal('organization.member_directory') }, - { status: 403 } - ) - } - - // Get organization members - const memberPageQuery = db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, organizationId)) - .orderBy(user.name, user.id) - .limit(limit) - .offset(offset) - - const totalQuery = db - .select({ value: count() }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - // Include usage data if requested and user has admin access - if (includeUsage && hasAdminAccess) { - const [base, totalRows] = await Promise.all([ - db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - currentUsageLimit: userStats.currentUsageLimit, - usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(user.id, userStats.userId)) - .where(eq(member.organizationId, organizationId)) - .orderBy(user.name, user.id) - .limit(limit) - .offset(offset), - totalQuery, - ]) - - const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot( - organizationId, - { - userIds: base.map((row) => row.userId), - } - ) - const billingPeriodStart = billingPeriod?.start ?? null - const billingPeriodEnd = billingPeriod?.end ?? null - - const membersWithUsage = base.map((row) => ({ + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizationMembers } from '@/lib/organizations/application/reads' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationMembersContract, + auth: internalSessionAuth, + operation: organizationOperations.listMembers, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization member-directory behavior.', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + limit: query.limit, + offset: query.offset, + sortBy: 'name' as const, + sortOrder: 'asc' as const, + includeUsage: query.include === 'usage', + }), + useCase: listOrganizationMembers, + present: ({ data, total, userRole, hasAdminAccess }, { input }) => { + if (total === undefined || input.offset === undefined) + throw new Error('Internal directory requires offset pagination') + return { + success: true, + data: data.map((row) => + organizationMemberUsageSchema.parse({ ...row, - currentPeriodCost: (usageByUser.get(row.userId) ?? 0).toString(), - billingPeriodStart, - billingPeriodEnd, - })) - - const total = totalRows[0]?.value ?? 0 - return NextResponse.json({ - success: true, - data: membersWithUsage, - total, - pagination: { - total, - limit, - offset, - hasMore: offset + membersWithUsage.length < total, - }, - userRole, - hasAdminAccess, + role: organizationRoleSchema.parse(row.role), + createdAt: row.createdAt.toISOString(), + ...(row.usageLimitUpdatedAt === undefined + ? {} + : { usageLimitUpdatedAt: row.usageLimitUpdatedAt?.toISOString() ?? null }), + ...(row.billingPeriodStart === undefined + ? {} + : { billingPeriodStart: row.billingPeriodStart?.toISOString() ?? null }), + ...(row.billingPeriodEnd === undefined + ? {} + : { billingPeriodEnd: row.billingPeriodEnd?.toISOString() ?? null }), }) - } - - const [members, totalRows] = await Promise.all([memberPageQuery, totalQuery]) - const total = totalRows[0]?.value ?? 0 - - return NextResponse.json({ - success: true, - data: members, + ), + total, + pagination: { total, - pagination: { - total, - limit, - offset, - hasMore: offset + members.length < total, - }, - userRole, - hasAdminAccess, - }) - } catch (error) { - logger.error('Failed to get organization members', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + limit: input.limit, + offset: input.offset, + hasMore: input.offset + data.length < total, + }, + userRole: organizationRoleSchema.parse(userRole), + hasAdminAccess, } - } -) + }, +}) diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index ba074c3ab5f..606c2919671 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -5,117 +5,55 @@ import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateOrganizationContract } from '@/lib/api/contracts/organization' +import { + getOrganizationContract, + updateOrganizationContract, +} from '@/lib/api/contracts/organization' +import { organizationRoleSchema } from '@/lib/api/contracts/primitives' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - getOrganizationSeatAnalytics, - getOrganizationSeatInfo, -} from '@/lib/billing/validation/seat-management' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { getOrganization } from '@/lib/organizations/application/reads' const logger = createLogger('OrganizationAPI') -type OrganizationDetailsResponse = { - success: true - data: { - id: string - name: string - slug: string | null - logo: string | null - metadata: unknown - createdAt: Date - updatedAt: Date - seats?: NonNullable>> - seatAnalytics?: NonNullable>> - } - userRole: string - hasAdminAccess: boolean -} - -/** - * GET /api/organizations/[id] - * Get organization details including settings and seat information - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId } = await params - const url = new URL(request.url) - const includeSeats = url.searchParams.get('include') === 'seats' - - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - const organizationEntry = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - if (organizationEntry.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - const userRole = memberEntry[0].role - const hasAdminAccess = isOrgAdminRole(userRole) - - const response: OrganizationDetailsResponse = { - success: true, - data: { - id: organizationEntry[0].id, - name: organizationEntry[0].name, - slug: organizationEntry[0].slug, - logo: organizationEntry[0].logo, - metadata: organizationEntry[0].metadata, - createdAt: organizationEntry[0].createdAt, - updatedAt: organizationEntry[0].updatedAt, - }, - userRole, - hasAdminAccess, - } - - if (includeSeats) { - const seatInfo = await getOrganizationSeatInfo(organizationId) - if (seatInfo) { - response.data.seats = seatInfo - } - - if (hasAdminAccess) { - const analytics = await getOrganizationSeatAnalytics(organizationId) - if (analytics) { - response.data.seatAnalytics = analytics - } - } - } - - return NextResponse.json(response) - } catch (error) { - logger.error('Failed to get organization', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getOrganizationContract, + auth: internalSessionAuth, + operation: organizationOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization metadata admission', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + includeSeats: query.include === 'seats', + }), + useCase: getOrganization, + present: (result) => ({ + success: true, + data: { + id: result.id, + name: result.name, + slug: result.slug, + logo: result.logo, + metadata: result.metadata, + createdAt: result.createdAt.toISOString(), + updatedAt: result.updatedAt.toISOString(), + ...(result.seats ? { seats: result.seats } : {}), + ...(result.seatAnalytics ? { seatAnalytics: result.seatAnalytics } : {}), + }, + userRole: organizationRoleSchema.parse(result.role), + hasAdminAccess: result.hasAdminAccess, + }), +}) /** * PUT /api/organizations/[id] diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route.ts new file mode 100644 index 00000000000..f2fad788a85 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route.ts @@ -0,0 +1,21 @@ +import { v2ResendOrganizationInvitationContract } from '@/lib/api/contracts/v2/organizations' +import { presentOrganizationInvitation } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { resendInvitation } from '@/lib/invitations/application/mutations' +import { invitationOperations } from '@/lib/invitations/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ResendOrganizationInvitationContract, + operation: invitationOperations.resend, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => ({ + invitationId: params.invitationId, + assertedOrganizationId: params.organizationId, + }), + parseOptions: { optionalJsonBody: true }, + useCase: resendInvitation, + present: (invitation) => ({ data: presentOrganizationInvitation(invitation) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route.ts new file mode 100644 index 00000000000..24aed2394de --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route.ts @@ -0,0 +1,38 @@ +import { + v2GetOrganizationInvitationContract, + v2RevokeOrganizationInvitationContract, +} from '@/lib/api/contracts/v2/organizations' +import { presentOrganizationInvitation } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { revokeInvitation } from '@/lib/invitations/application/mutations' +import { invitationOperations } from '@/lib/invitations/application/operations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { getOrganizationInvitation } from '@/lib/organizations/application/reads' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationInvitationContract, + operation: organizationOperations.readInvitation, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getOrganizationInvitation, + present: (invitation) => ({ data: presentOrganizationInvitation(invitation) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2RevokeOrganizationInvitationContract, + operation: invitationOperations.revoke, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => ({ + invitationId: params.invitationId, + assertedOrganizationId: params.organizationId, + }), + useCase: revokeInvitation, + present: (invitation) => ({ + data: { id: invitation.invitation.id, status: 'cancelled' as const }, + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/invitations/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/route.ts new file mode 100644 index 00000000000..f2e6ac34061 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/route.ts @@ -0,0 +1,57 @@ +import { + v2CreateOrganizationInvitationContract, + v2ListOrganizationInvitationsContract, +} from '@/lib/api/contracts/v2/organizations' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { presentOrganizationInvitation } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { createOrganizationInvitation } from '@/lib/organizations/application/invitations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizationInvitations } from '@/lib/organizations/application/reads' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationInvitationsContract, + operation: organizationOperations.listInvitations, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationInvitationsContract, params), { + search: query.search, + status: query.status, + }) + ), + }), + useCase: listOrganizationInvitations, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data.map(presentOrganizationInvitation), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationInvitationsContract, params), { + search: query.search, + status: query.status, + }) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateOrganizationInvitationContract, + operation: organizationOperations.createInvitation, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: createOrganizationInvitation, + present: (invitation) => ({ data: presentOrganizationInvitation(invitation) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/route.ts new file mode 100644 index 00000000000..b62ad453d94 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/route.ts @@ -0,0 +1,34 @@ +import { + v2RemoveOrganizationMemberContract, + v2UpdateOrganizationMemberContract, +} from '@/lib/api/contracts/v2/organizations' +import { presentOrganizationMember } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { + removeOrganizationMember, + updateOrganizationMember, +} from '@/lib/organizations/application/members' +import { organizationOperations } from '@/lib/organizations/application/operations' + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateOrganizationMemberContract, + operation: organizationOperations.updateMember, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateOrganizationMember, + present: ({ member }) => ({ data: presentOrganizationMember(member) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2RemoveOrganizationMemberContract, + operation: organizationOperations.removeMember, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => params, + useCase: removeOrganizationMember, + present: ({ target }) => ({ data: { userId: target.userId, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/members/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/members/route.ts new file mode 100644 index 00000000000..ff3228d0dc6 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/members/route.ts @@ -0,0 +1,40 @@ +import { v2ListOrganizationMembersContract } from '@/lib/api/contracts/v2/organizations' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { presentOrganizationMember } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizationMembers } from '@/lib/organizations/application/reads' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationMembersContract, + operation: organizationOperations.listMembers, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationMembersContract, params), { + search: query.search, + }) + ), + }), + useCase: listOrganizationMembers, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data.map(presentOrganizationMember), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationMembersContract, params), { + search: query.search, + }) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route.ts similarity index 89% rename from apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts rename to apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route.ts index 8359515e81e..895bb08de90 100644 --- a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route.ts @@ -12,5 +12,5 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: v2PermissionGroupErrorPolicy, mapInput: ({ params }) => params, useCase: removePermissionGroupMember, - present: ({ member }) => ({ data: { id: member.id, deleted: true as const } }), + present: ({ member }) => ({ data: { userId: member.userId, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts deleted file mode 100644 index c3167c9940c..00000000000 --- a/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { v2ListPermissionGroupWorkspacesContract } from '@/lib/api/contracts/v2/permission-groups' -import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { v2PermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' -import { permissionGroupOperations } from '@/lib/permission-groups/application/operations' -import { listPermissionGroupWorkspaces } from '@/lib/permission-groups/application/use-cases' -import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' - -function cursorFilters(params: { organizationId: string }, query: { search?: string }) { - return cursorScopeKey(cursorRoute(v2ListPermissionGroupWorkspacesContract, params), { - search: query.search, - }) -} - -export const GET = defineV2JsonRoute({ - contract: v2ListPermissionGroupWorkspacesContract, - auth: v2ApiKeyAuth, - operation: permissionGroupOperations.listWorkspaces, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2PermissionGroupErrorPolicy, - mapInput: ({ params, query }) => ({ - ...params, - ...query, - cursorKeys: readSortedCursor( - query.cursor, - query.sortBy, - query.sortOrder, - cursorFilters(params, query) - ), - }), - useCase: listPermissionGroupWorkspaces, - present: ({ data, nextCursorKeys }, { params, query }) => ({ - data, - nextCursor: writeSortedCursor( - nextCursorKeys, - query.sortBy, - query.sortOrder, - cursorFilters(params, query) - ), - }), -}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/route.ts new file mode 100644 index 00000000000..83acbf1629b --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/route.ts @@ -0,0 +1,17 @@ +import { v2GetOrganizationContract } from '@/lib/api/contracts/v2/organizations' +import { presentOrganization } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { getOrganization } from '@/lib/organizations/application/reads' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationContract, + operation: organizationOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getOrganization, + present: (organization) => ({ data: presentOrganization(organization) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/workspaces/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/workspaces/route.ts new file mode 100644 index 00000000000..39358c4bdfc --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/workspaces/route.ts @@ -0,0 +1,39 @@ +import { v2ListOrganizationWorkspacesContract } from '@/lib/api/contracts/v2/organizations' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizationWorkspaces } from '@/lib/organizations/application/reads' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationWorkspacesContract, + operation: organizationOperations.listWorkspaces, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationWorkspacesContract, params), { + search: query.search, + }) + ), + }), + useCase: listOrganizationWorkspaces, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data, + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationWorkspacesContract, params), { + search: query.search, + }) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/route.test.ts b/apps/sim/app/api/v2/organizations/route.test.ts new file mode 100644 index 00000000000..7f5c3b09e7a --- /dev/null +++ b/apps/sim/app/api/v2/organizations/route.test.ts @@ -0,0 +1,169 @@ +/** @vitest-environment node */ +import { recordAudit } from '@sim/audit' +import { member } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauth: vi.fn(), + rate: vi.fn(), + config: vi.fn(), + invitation: vi.fn(), + resend: vi.fn(), + Unauthenticated: class extends Error {}, +})) +vi.mock('@sim/audit', async (original) => ({ + ...(await original()), + recordAudit: vi.fn(), +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: mocks.Unauthenticated, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauth + checkRateLimitDirectOrThrow = mocks.rate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.config, +})) +vi.mock('@/lib/invitations/core', () => ({ getInvitationById: mocks.invitation })) +vi.mock('@/lib/invitations/mutation-manager', () => ({ + resendInvitationRecord: mocks.resend, + revokeInvitationRecord: vi.fn(), +})) + +import { dispatchMcpOperation } from '@/lib/api/mcp/dispatch' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST } from '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route' + +const principal = { kind: 'personal_api_key', userId: 'actor', keyId: 'key' } as const +const params = { organizationId: 'org', invitationId: 'invite' } +const context = { params: Promise.resolve(params) } +const inv = { + id: 'invite', + organizationId: 'org', + email: 'person@example.com', + role: 'member', + kind: 'organization', + membershipIntent: 'internal', + status: 'pending', + token: 'SECRET', + grants: [], + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2099-01-01'), +} +function request(body?: unknown) { + return new NextRequest('http://localhost/api/v2/organizations/org/invitations/invite/resend', { + method: 'POST', + headers: { + 'x-api-key': 'key', + 'content-type': 'application/json', + 'x-forwarded-for': '127.0.0.1', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['key:key'], + rateLimitSubscription: null, + }) + const admission = { + allowed: true, + remaining: 99, + resetAt: new Date(Date.now() + 60_000), + retryAfterMs: 0, + } + mocks.preauth.mockResolvedValue(admission) + mocks.rate.mockResolvedValue(admission) + mocks.config.mockResolvedValue(null) + mocks.invitation.mockResolvedValue(inv) + mocks.resend.mockResolvedValue(inv) +}) + +describe('organization invitation API and MCP', () => { + it.each([undefined, {}])( + 'accepts a bodyless or empty resend and never returns tokens', + async (body) => { + queueTableRows(member, [{ role: 'admin' }]) + const response = await POST(request(body), context) + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload).toMatchObject({ data: { id: 'invite', organizationId: 'org' } }) + expect(payload.data).not.toHaveProperty('token') + expect(mocks.resend).toHaveBeenCalledWith( + expect.objectContaining({ actorUserId: 'actor', assertedOrganizationId: 'org' }) + ) + } + ) + + it('rejects unknown action fields before protected loading', async () => { + expect((await POST(request({ role: 'owner' }), context)).status).toBe(400) + expect(mocks.invitation).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it.each([ + ['member', 403], + [null, 404], + ] as const)('refuses org role %s', async (role, status) => { + queueTableRows(member, role ? [{ role }] : []) + expect((await POST(request(), context)).status).toBe(status) + expect(mocks.resend).not.toHaveBeenCalled() + }) + + it('conceals a cross-organization invitation', async () => { + mocks.invitation.mockResolvedValue({ ...inv, organizationId: 'another' }) + expect((await POST(request(), context)).status).toBe(404) + expect(mocks.resend).not.toHaveBeenCalled() + }) + + it('refuses workspace keys before canonical loading', async () => { + mocks.authenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws', keyId: 'key' }, + keyType: 'workspace', + rateLimitSubjectIds: ['key:key'], + rateLimitSubscription: null, + }) + expect((await POST(request(), context)).status).toBe(403) + expect(mocks.invitation).not.toHaveBeenCalled() + }) + + it('rechecks the organization credential restriction', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + expect((await POST(request(), context)).status).toBe(403) + expect(mocks.resend).not.toHaveBeenCalled() + }) + + it('dispatches MCP through the same route and schema', async () => { + queueTableRows(member, [{ role: 'admin' }]) + const result = await dispatchMcpOperation( + { operation: 'resendOrganizationInvitation', params }, + { + inbound: request(), + credential: { apiKey: 'key', bearer: null }, + audience: { resource: 'https://mcp.sim.test/mcp', allowUnboundApiTokens: true }, + signal: new AbortController().signal, + } + ) + expect(result.isError).not.toBe(true) + const content = result.content[0] + if (content.type !== 'text') throw new Error('Expected JSON tool output') + expect(JSON.parse(content.text)).toMatchObject({ data: { id: 'invite' } }) + expect(content.text).not.toContain('SECRET') + }) +}) diff --git a/apps/sim/app/api/v2/organizations/route.ts b/apps/sim/app/api/v2/organizations/route.ts new file mode 100644 index 00000000000..673322680e1 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/route.ts @@ -0,0 +1,35 @@ +import { v2ListOrganizationsContract } from '@/lib/api/contracts/v2/organizations' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { presentOrganization } from '@/lib/api/server/organization-presenters' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizations } from '@/lib/organizations/application/reads' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationsContract, + operation: organizationOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ query }) => ({ + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationsContract, {}), { search: query.search }) + ), + }), + useCase: listOrganizations, + present: ({ data, nextCursorKeys }, { query }) => ({ + data: data.map(presentOrganization), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationsContract, {}), { search: query.search }) + ), + }), +}) diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 905fb499ae4..e256d25585c 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -5,10 +5,8 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { - invitationOperations, - sendInvitationBatch, -} from '@/lib/invitations/application/send-invitation-batch' +import { invitationOperations } from '@/lib/invitations/application/operations' +import { sendInvitationBatch } from '@/lib/invitations/application/send-invitation-batch' import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index 35ad7fb972a..fd331bec218 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -518,6 +518,42 @@ export const transferOwnershipContract = defineRouteContract({ }, }) +export const organizationSeatInfoSchema = z.object({ + organizationId: z.string(), + organizationName: z.string(), + currentSeats: z.number(), + maxSeats: z.number(), + availableSeats: z.number(), + subscriptionPlan: z.string(), + canAddSeats: z.boolean(), +}) +export const getOrganizationQuerySchema = z.object({ include: z.string().max(100).optional() }) +export type GetOrganizationQuery = z.input +export const getOrganizationResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + logo: z.string().nullable(), + metadata: z.unknown().describe('Organization-defined JSON metadata.'), + createdAt: z.string(), + updatedAt: z.string(), + seats: organizationSeatInfoSchema.optional(), + seatAnalytics: organizationSeatInfoSchema.extend({ utilizationRate: z.number() }).optional(), + }), + userRole: organizationRoleSchema, + hasAdminAccess: z.boolean(), +}) +export type GetOrganizationResponse = z.output +export const getOrganizationContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]', + params: organizationParamsSchema, + query: getOrganizationQuerySchema, + response: { mode: 'json', schema: getOrganizationResponseSchema }, +}) + export const updateOrganizationContract = defineRouteContract({ method: 'PUT', path: '/api/organizations/[id]', 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 a38c118cce1..21385ead7c3 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 @@ -45,9 +45,12 @@ import { /** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */ const PAGED_LISTS = [ + 'GET /api/v2/organizations', + 'GET /api/v2/organizations/[organizationId]/members', + 'GET /api/v2/organizations/[organizationId]/invitations', + 'GET /api/v2/organizations/[organizationId]/workspaces', 'GET /api/v2/organizations/[organizationId]/permission-groups', 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', - 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces', 'GET /api/v2/audit-logs', 'GET /api/v2/billing/logs', @@ -151,13 +154,17 @@ const FULL_SET_LISTS = [ * therefore fails here until someone decides whether the cursor is bound to it. */ const CURSOR_BINDINGS: Record = { - 'GET /api/v2/organizations/[organizationId]/permission-groups': ['search', 'sortBy', 'sortOrder'], - 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members': [ + 'GET /api/v2/organizations': ['search', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/members': ['search', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/invitations': [ + 'search', + 'status', 'sortBy', 'sortOrder', ], - 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces': [ - 'search', + 'GET /api/v2/organizations/[organizationId]/workspaces': ['search', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/permission-groups': ['search', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members': [ 'sortBy', 'sortOrder', ], @@ -304,12 +311,14 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/organizations/[organizationId]/members': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/invitations': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/workspaces': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/permission-groups': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members': [ 'organizationId', 'groupId', ], - 'GET /api/v2/organizations/[organizationId]/permission-groups/workspaces': ['organizationId'], 'GET /api/v2/files/[fileId]/versions': ['fileId'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['knowledgeBaseId'], diff --git a/apps/sim/lib/api/contracts/v2/openapi/organizations.ts b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts new file mode 100644 index 00000000000..b43dc97c302 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts @@ -0,0 +1,490 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2CreateOrganizationInvitationContract, + v2GetOrganizationContract, + v2GetOrganizationInvitationContract, + v2ListOrganizationInvitationsContract, + v2ListOrganizationMembersContract, + v2ListOrganizationsContract, + v2ListOrganizationWorkspacesContract, + v2RemoveOrganizationMemberContract, + v2ResendOrganizationInvitationContract, + v2RevokeOrganizationInvitationContract, + v2UpdateOrganizationMemberContract, +} from '@/lib/api/contracts/v2/organizations' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { invitationOperations } from '@/lib/invitations/application/operations' +import { organizationOperations } from '@/lib/organizations/application/operations' + +const TIMESTAMP = '2026-06-01T09:00:00.000Z' + +export const organizationOpenApiRoutes = [ + defineOpenApiRoute( + v2ListOrganizationsContract, + { + applicationOperation: organizationOperations.list, + operationId: 'listOrganizations', + summary: 'List Organizations', + description: `List organizations the acting user belongs to. Organizations that disallow the calling credential are omitted. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { description: 'List Organizations result.', headers: RATE_LIMIT_HEADERS }, + }, + { + query: documentedSchema( + v2ListOrganizationsContract.query, + 'ListOrganizationsQuery', + 'List Organizations query', + 'Filtering and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationsContract.response.schema, + 'ListOrganizationsResponse', + 'List Organizations response', + 'List Organizations result.', + [ + { + data: [ + { + id: 'org-123', + name: 'Example Organization', + slug: 'example', + logo: null, + role: 'admin', + createdAt: TIMESTAMP, + }, + ], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetOrganizationContract, + { + applicationOperation: organizationOperations.read, + operationId: 'getOrganization', + summary: 'Get Organization', + description: `Get organization metadata and the acting user’s organization role. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { description: 'Get Organization result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetOrganizationContract.params, + 'GetOrganizationParams', + 'Get Organization parameters', + 'Organization and resource identifiers.' + ), + query: v2GetOrganizationContract.query, + response: documentedSchema( + v2GetOrganizationContract.response.schema, + 'GetOrganizationResponse', + 'Get Organization response', + 'Get Organization result.', + [ + { + data: { + id: 'org-123', + name: 'Example Organization', + slug: 'example', + logo: null, + role: 'admin', + createdAt: TIMESTAMP, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationWorkspacesContract, + { + applicationOperation: organizationOperations.listWorkspaces, + operationId: 'listOrganizationWorkspaces', + summary: 'List Organization Workspaces', + description: `List active workspaces owned by the organization. Requires organization administrator access; does not require Access Control. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { description: 'List Organization Workspaces result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListOrganizationWorkspacesContract.params, + 'ListOrganizationWorkspacesParams', + 'List Organization Workspaces parameters', + 'Organization and resource identifiers.' + ), + query: documentedSchema( + v2ListOrganizationWorkspacesContract.query, + 'ListOrganizationWorkspacesQuery', + 'List Organization Workspaces query', + 'Filtering and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationWorkspacesContract.response.schema, + 'ListOrganizationWorkspacesResponse', + 'List Organization Workspaces response', + 'List Organization Workspaces result.', + [{ data: [{ id: 'workspace-123', name: 'Engineering' }], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationMembersContract, + { + applicationOperation: organizationOperations.listMembers, + operationId: 'listOrganizationMembers', + summary: 'List Organization Members', + description: `List organization members by name or email. Ordinary members must have access to the member directory; organization administrators retain access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { description: 'List Organization Members result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListOrganizationMembersContract.params, + 'ListOrganizationMembersParams', + 'List Organization Members parameters', + 'Organization and resource identifiers.' + ), + query: documentedSchema( + v2ListOrganizationMembersContract.query, + 'ListOrganizationMembersQuery', + 'List Organization Members query', + 'Filtering and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationMembersContract.response.schema, + 'ListOrganizationMembersResponse', + 'List Organization Members response', + 'List Organization Members result.', + [ + { + data: [ + { + userId: 'user-123', + name: 'Example Member', + email: 'member@example.com', + role: 'member', + joinedAt: TIMESTAMP, + }, + ], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2UpdateOrganizationMemberContract, + { + applicationOperation: organizationOperations.updateMember, + operationId: 'updateOrganizationMember', + summary: 'Update Organization Member', + description: `Change a member’s organization role. Requires organization administrator access. The owner’s role and memberships managed by an identity provider cannot be changed here. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Update Organization Member result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateOrganizationMemberContract.params, + 'UpdateOrganizationMemberParams', + 'Update Organization Member parameters', + 'Organization and resource identifiers.' + ), + query: v2UpdateOrganizationMemberContract.query, + body: documentedSchema( + v2UpdateOrganizationMemberContract.body, + 'UpdateOrganizationMemberBody', + 'Update Organization Member body', + 'Update Organization Member input.', + [{ role: 'admin' }] + ), + response: documentedSchema( + v2UpdateOrganizationMemberContract.response.schema, + 'UpdateOrganizationMemberResponse', + 'Update Organization Member response', + 'Update Organization Member result.', + [ + { + data: { + userId: 'user-123', + name: 'Example Member', + email: 'member@example.com', + role: 'admin', + joinedAt: TIMESTAMP, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RemoveOrganizationMemberContract, + { + applicationOperation: organizationOperations.removeMember, + operationId: 'removeOrganizationMember', + summary: 'Remove Organization Member', + description: `Remove a member and revoke their access to organization workspaces. Administrators may remove members; members may remove themselves. The organization owner cannot be removed. Owned organization resources are reassigned and the departing member’s sessions end. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Remove Organization Member result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RemoveOrganizationMemberContract.params, + 'RemoveOrganizationMemberParams', + 'Remove Organization Member parameters', + 'Organization and resource identifiers.' + ), + query: v2RemoveOrganizationMemberContract.query, + response: documentedSchema( + v2RemoveOrganizationMemberContract.response.schema, + 'RemoveOrganizationMemberResponse', + 'Remove Organization Member response', + 'Remove Organization Member result.', + [{ data: { userId: 'user-123', deleted: true } }] + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationInvitationsContract, + { + applicationOperation: organizationOperations.listInvitations, + operationId: 'listOrganizationInvitations', + summary: 'List Organization Invitations', + description: `List invitations owned by the organization, including invitations with workspace grants. Requires organization administrator access. Expired invitations are reported without modifying them. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'List Organization Invitations result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListOrganizationInvitationsContract.params, + 'ListOrganizationInvitationsParams', + 'List Organization Invitations parameters', + 'Organization and resource identifiers.' + ), + query: documentedSchema( + v2ListOrganizationInvitationsContract.query, + 'ListOrganizationInvitationsQuery', + 'List Organization Invitations query', + 'Filtering and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationInvitationsContract.response.schema, + 'ListOrganizationInvitationsResponse', + 'List Organization Invitations response', + 'List Organization Invitations result.', + [ + { + data: [ + { + id: 'invitation-123', + organizationId: 'org-123', + email: 'member@example.com', + role: 'member', + kind: 'organization', + membershipIntent: 'internal', + status: 'pending', + createdAt: TIMESTAMP, + expiresAt: '2026-06-08T09:00:00.000Z', + }, + ], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateOrganizationInvitationContract, + { + applicationOperation: organizationOperations.createInvitation, + operationId: 'createOrganizationInvitation', + summary: 'Create Organization Invitation', + description: `Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Create Organization Invitation result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateOrganizationInvitationContract.params, + 'CreateOrganizationInvitationParams', + 'Create Organization Invitation parameters', + 'Organization and resource identifiers.' + ), + query: v2CreateOrganizationInvitationContract.query, + body: documentedSchema( + v2CreateOrganizationInvitationContract.body, + 'CreateOrganizationInvitationBody', + 'Create Organization Invitation body', + 'Create Organization Invitation input.', + [{ email: 'member@example.com', role: 'member' }] + ), + response: documentedSchema( + v2CreateOrganizationInvitationContract.response.schema, + 'CreateOrganizationInvitationResponse', + 'Create Organization Invitation response', + 'Create Organization Invitation result.', + [ + { + data: { + id: 'invitation-123', + organizationId: 'org-123', + email: 'member@example.com', + role: 'member', + kind: 'organization', + membershipIntent: 'internal', + status: 'pending', + createdAt: TIMESTAMP, + expiresAt: '2026-06-08T09:00:00.000Z', + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetOrganizationInvitationContract, + { + applicationOperation: organizationOperations.readInvitation, + operationId: 'getOrganizationInvitation', + summary: 'Get Organization Invitation', + description: `Get an invitation owned by the organization. Requires organization administrator access. The response excludes the acceptance token. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { description: 'Get Organization Invitation result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetOrganizationInvitationContract.params, + 'GetOrganizationInvitationParams', + 'Get Organization Invitation parameters', + 'Organization and resource identifiers.' + ), + query: v2GetOrganizationInvitationContract.query, + response: documentedSchema( + v2GetOrganizationInvitationContract.response.schema, + 'GetOrganizationInvitationResponse', + 'Get Organization Invitation response', + 'Get Organization Invitation result.', + [ + { + data: { + id: 'invitation-123', + organizationId: 'org-123', + email: 'member@example.com', + role: 'member', + kind: 'organization', + membershipIntent: 'internal', + status: 'pending', + createdAt: TIMESTAMP, + expiresAt: '2026-06-08T09:00:00.000Z', + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ResendOrganizationInvitationContract, + { + applicationOperation: invitationOperations.resend, + operationId: 'resendOrganizationInvitation', + summary: 'Resend Organization Invitation', + description: `Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Resend Organization Invitation result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ResendOrganizationInvitationContract.params, + 'ResendOrganizationInvitationParams', + 'Resend Organization Invitation parameters', + 'Organization and resource identifiers.' + ), + query: v2ResendOrganizationInvitationContract.query, + body: documentedSchema( + v2ResendOrganizationInvitationContract.body, + 'ResendOrganizationInvitationBody', + 'Resend Organization Invitation body', + 'Resend Organization Invitation input.', + [{}] + ), + response: documentedSchema( + v2ResendOrganizationInvitationContract.response.schema, + 'ResendOrganizationInvitationResponse', + 'Resend Organization Invitation response', + 'Resend Organization Invitation result.', + [ + { + data: { + id: 'invitation-123', + organizationId: 'org-123', + email: 'member@example.com', + role: 'member', + kind: 'organization', + membershipIntent: 'internal', + status: 'pending', + createdAt: TIMESTAMP, + expiresAt: '2026-06-08T09:00:00.000Z', + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RevokeOrganizationInvitationContract, + { + applicationOperation: invitationOperations.revoke, + operationId: 'revokeOrganizationInvitation', + summary: 'Revoke Organization Invitation', + description: `Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Revoke Organization Invitation result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RevokeOrganizationInvitationContract.params, + 'RevokeOrganizationInvitationParams', + 'Revoke Organization Invitation parameters', + 'Organization and resource identifiers.' + ), + query: v2RevokeOrganizationInvitationContract.query, + response: documentedSchema( + v2RevokeOrganizationInvitationContract.response.schema, + 'RevokeOrganizationInvitationResponse', + 'Revoke Organization Invitation response', + 'Revoke Organization Invitation result.', + [{ data: { id: 'invitation-123', status: 'cancelled' } }] + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts index 9eddf91ad13..137ab4a6d1d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts @@ -13,7 +13,6 @@ import { v2GetPermissionGroupContract, v2ListPermissionGroupMembersContract, v2ListPermissionGroupsContract, - v2ListPermissionGroupWorkspacesContract, v2RemovePermissionGroupMemberContract, v2UpdatePermissionGroupContract, } from '@/lib/api/contracts/v2/permission-groups' @@ -293,7 +292,7 @@ export const permissionGroupOpenApiRoutes = [ applicationOperation: permissionGroupOperations.removeMember, operationId: 'removePermissionGroupMember', summary: 'Remove Permission Group Member', - description: `Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. ${AUTHORITY}`, + description: `Remove a member by user identifier. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. ${AUTHORITY}`, tags: ['Permission Groups'], errors: RESOURCE_CONFLICT_ERRORS, success: { @@ -314,7 +313,7 @@ export const permissionGroupOpenApiRoutes = [ 'RemovePermissionGroupMemberResponse', 'Remove Permission Group Member response', 'Remove Permission Group Member result.', - [{ data: { id: 'assignment-123', deleted: true } }] + [{ data: { userId: 'user-123', deleted: true } }] ), } ), @@ -356,40 +355,4 @@ export const permissionGroupOpenApiRoutes = [ ), } ), - defineOpenApiRoute( - v2ListPermissionGroupWorkspacesContract, - { - applicationOperation: permissionGroupOperations.listWorkspaces, - operationId: 'listPermissionGroupWorkspaces', - summary: 'List Permission Group Workspaces', - description: `List organization workspaces available for permission-group scope selection with cursor pagination. ${AUTHORITY}`, - tags: ['Permission Groups'], - errors: RESOURCE_ERRORS, - success: { - description: 'List Permission Group Workspaces result.', - headers: RATE_LIMIT_HEADERS, - }, - }, - { - params: documentedSchema( - v2ListPermissionGroupWorkspacesContract.params, - 'ListPermissionGroupWorkspacesParams', - 'Organization parameters', - 'Organization identifier.' - ), - query: documentedSchema( - v2ListPermissionGroupWorkspacesContract.query, - 'ListPermissionGroupWorkspacesQuery', - 'Permission group list query', - 'Pagination and ordering controls.' - ), - response: documentedSchema( - v2ListPermissionGroupWorkspacesContract.response.schema, - 'ListPermissionGroupWorkspacesResponse', - 'List Permission Group Workspaces response', - 'List Permission Group Workspaces result.', - [{ data: [{ id: 'workspace-123', name: 'Engineering' }], nextCursor: null }] - ), - } - ), ] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 7c7eaf496d8..16e6eb96e1e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -31,6 +31,7 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { organizationOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/organizations' import { permissionGroupOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/permission-groups' import { documentedSchema, @@ -2149,6 +2150,7 @@ const declaredRoutes = [ } ), ...permissionGroupOpenApiRoutes, + ...organizationOpenApiRoutes, ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -2172,6 +2174,10 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, servers: [{ url: 'https://www.sim.ai', description: 'Production' }], tags: [ + { + name: 'Organizations', + description: 'Discover organizations and manage their members and invitations.', + }, { name: 'Permission Groups', description: 'Manage organization permission groups, their restrictions, and membership.', diff --git a/apps/sim/lib/api/contracts/v2/organizations.ts b/apps/sim/lib/api/contracts/v2/organizations.ts new file mode 100644 index 00000000000..6149b1ff26b --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/organizations.ts @@ -0,0 +1,288 @@ +import { z } from 'zod' +import { + noInputSchema, + nonEmptyIdSchema, + organizationIdSchema, + organizationRoleSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' + +export const v2OrganizationParamsSchema = z + .object({ + organizationId: organizationIdSchema.describe('Organization identifier.'), + }) + .strict() +export type V2OrganizationParams = z.input +export const v2OrganizationMemberParamsSchema = v2OrganizationParamsSchema.extend({ + userId: nonEmptyIdSchema.describe('User identifier of the organization member.'), +}) +export type V2OrganizationMemberParams = z.input +export const v2OrganizationInvitationParamsSchema = v2OrganizationParamsSchema.extend({ + invitationId: nonEmptyIdSchema.describe('Invitation identifier.'), +}) +export type V2OrganizationInvitationParams = z.input + +export const v2OrganizationSchema = z + .object({ + id: z.string().describe('Organization identifier.'), + name: z.string().describe('Organization display name.'), + slug: z.string().describe('Organization slug, or null when unset.'), + logo: z.string().nullable().describe('Organization logo URL, or null when unset.'), + role: organizationRoleSchema.describe('The acting user’s role in this organization.'), + createdAt: v2TimestampSchema.describe('When the organization was created.'), + }) + .meta({ + id: 'V2Organization', + title: 'Organization', + description: 'An organization the acting user belongs to.', + }) +export type V2Organization = z.output + +export const v2OrganizationMemberSchema = z + .object({ + userId: z + .string() + .describe('User identifier; use this identifier to update or remove the member.'), + name: z.string().describe('Member display name.'), + email: z.string().describe('Member email address.'), + role: organizationRoleSchema.describe( + 'Organization role; separate from workspace permissions.' + ), + joinedAt: v2TimestampSchema.describe('When the user joined the organization.'), + }) + .meta({ + id: 'V2OrganizationMember', + title: 'Organization member', + description: 'An organization membership identified by user ID.', + }) +export type V2OrganizationMember = z.output + +export const v2OrganizationWorkspaceSchema = z + .object({ + id: z.string().describe('Workspace identifier.'), + name: z.string().describe('Workspace display name.'), + }) + .meta({ + id: 'V2OrganizationWorkspace', + title: 'Organization workspace', + description: 'A workspace owned by the organization.', + }) +export type V2OrganizationWorkspace = z.output + +export const v2OrganizationInvitationSchema = z + .object({ + id: z.string().describe('Invitation identifier.'), + organizationId: z.string().describe('Organization that owns the invitation.'), + email: z.string().describe('Email address of the invitee.'), + role: z.enum(['member', 'admin']).describe('Organization role offered to an internal invitee.'), + kind: z + .enum(['organization', 'workspace']) + .describe('Whether the invitation originated from organization or workspace administration.'), + membershipIntent: z + .enum(['internal', 'external']) + .describe('Whether acceptance joins the organization or grants workspace access only.'), + status: z + .enum(['pending', 'accepted', 'rejected', 'cancelled', 'expired']) + .describe('Current invitation status; elapsed pending invitations are reported as expired.'), + createdAt: v2TimestampSchema.describe('When the invitation was created.'), + expiresAt: v2TimestampSchema.describe('When the invitation expires.'), + }) + .meta({ + id: 'V2OrganizationInvitation', + title: 'Organization invitation', + description: 'Invitation metadata without its acceptance token.', + }) +export type V2OrganizationInvitation = z.output + +export const v2ListOrganizationsQuerySchema = z + .object({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the organization name.' + ), + ...v2SortFields(['name', 'createdAt'] as const, { sortBy: 'name', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum organizations to return per page.' }), + }) + .strict() +export type V2ListOrganizationsQuery = z.output +export const v2ListOrganizationMembersQuerySchema = z + .object({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against member name or email.' + ), + ...v2SortFields(['name', 'email', 'joinedAt'] as const, { sortBy: 'name', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum members to return per page.' }), + }) + .strict() +export type V2ListOrganizationMembersQuery = z.output +export const v2ListOrganizationWorkspacesQuerySchema = z + .object({ + search: v2SearchSchema.describe('Case-insensitive substring match against the workspace name.'), + ...v2SortFields(['name', 'id'] as const, { sortBy: 'name', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum workspaces to return per page.' }), + }) + .strict() +export type V2ListOrganizationWorkspacesQuery = z.output< + typeof v2ListOrganizationWorkspacesQuerySchema +> +export const v2ListOrganizationInvitationsQuerySchema = z + .object({ + search: v2SearchSchema.describe('Case-insensitive substring match against the invitee email.'), + status: z + .enum(['pending', 'accepted', 'rejected', 'cancelled', 'expired']) + .optional() + .describe('Filter by current invitation status. Omit to include all statuses.'), + ...v2SortFields(['email', 'createdAt'] as const, { sortBy: 'createdAt', sortOrder: 'desc' }), + ...v2PaginationFields({ description: 'Maximum invitations to return per page.' }), + }) + .strict() +export type V2ListOrganizationInvitationsQuery = z.output< + typeof v2ListOrganizationInvitationsQuerySchema +> +export const v2UpdateOrganizationMemberBodySchema = z + .object({ + role: z + .enum(['member', 'admin']) + .describe('New organization role. Ownership transfers use a separate operation.'), + }) + .strict() +export type V2UpdateOrganizationMemberBody = z.input +export const v2CreateOrganizationInvitationBodySchema = z + .object({ + email: z + .string() + .trim() + .min(1, 'email is required') + .max(254, 'email cannot exceed 254 characters') + .email('email must be a valid email address') + .describe('Email address of the person to invite.'), + role: z + .enum(['member', 'admin']) + .default('member') + .describe( + 'Organization role to offer. Defaults to member; grants no workspace-specific permissions.' + ), + }) + .strict() +export type V2CreateOrganizationInvitationBody = z.input< + typeof v2CreateOrganizationInvitationBodySchema +> + +export const v2ListOrganizationsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations', + query: v2ListOrganizationsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationSchema) }, +}) +export const v2GetOrganizationContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]', + params: v2OrganizationParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationSchema) }, +}) +export const v2ListOrganizationWorkspacesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/workspaces', + params: v2OrganizationParamsSchema, + query: v2ListOrganizationWorkspacesQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationWorkspaceSchema) }, +}) +export const v2ListOrganizationMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/members', + params: v2OrganizationParamsSchema, + query: v2ListOrganizationMembersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationMemberSchema) }, +}) +export const v2UpdateOrganizationMemberContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/members/[userId]', + params: v2OrganizationMemberParamsSchema, + query: noInputSchema, + body: v2UpdateOrganizationMemberBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationMemberSchema) }, +}) +export const v2RemoveOrganizationMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/members/[userId]', + params: v2OrganizationMemberParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + userId: z.string().describe('User removed from the organization.'), + deleted: z + .literal(true) + .describe('Whether membership and organization workspace access were removed.'), + }) + .meta({ + id: 'V2OrganizationMemberDeletion', + title: 'Organization member removal', + description: 'Acknowledges removal of an organization member.', + }) + ), + }, +}) +export const v2ListOrganizationInvitationsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations', + params: v2OrganizationParamsSchema, + query: v2ListOrganizationInvitationsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationInvitationSchema) }, +}) +export const v2CreateOrganizationInvitationContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/invitations', + params: v2OrganizationParamsSchema, + query: noInputSchema, + body: v2CreateOrganizationInvitationBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationInvitationSchema), status: 201 }, +}) +export const v2GetOrganizationInvitationContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', + params: v2OrganizationInvitationParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationInvitationSchema) }, +}) +export const v2RevokeOrganizationInvitationContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', + params: v2OrganizationInvitationParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + id: z.string().describe('Revoked invitation identifier.'), + status: z + .literal('cancelled') + .describe('Revocation cancels the invitation and prevents acceptance.'), + }) + .meta({ + id: 'V2OrganizationInvitationRevocation', + title: 'Organization invitation revocation', + description: 'Acknowledges cancellation of a pending invitation.', + }) + ), + }, +}) +export const v2ResendOrganizationInvitationContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend', + params: v2OrganizationInvitationParamsSchema, + query: noInputSchema, + body: noInputSchema.optional().default({}), + response: { mode: 'json', schema: v2DataResponse(v2OrganizationInvitationSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/permission-groups.ts b/apps/sim/lib/api/contracts/v2/permission-groups.ts index 5449c8ac2a9..26b5324f7f0 100644 --- a/apps/sim/lib/api/contracts/v2/permission-groups.ts +++ b/apps/sim/lib/api/contracts/v2/permission-groups.ts @@ -32,9 +32,7 @@ export const v2PermissionGroupParamsSchema = v2PermissionGroupOrganizationParams }) export type V2PermissionGroupParams = z.input export const v2PermissionGroupMemberParamsSchema = v2PermissionGroupParamsSchema.extend({ - memberId: nonEmptyIdSchema.describe( - 'Membership assignment identifier returned by List Permission Group Members.' - ), + userId: nonEmptyIdSchema.describe('User identifier of the member to remove.'), }) export type V2PermissionGroupMemberParams = z.input @@ -107,25 +105,19 @@ export const v2ListPermissionGroupMembersQuerySchema = z export type V2ListPermissionGroupMembersQuery = z.output< typeof v2ListPermissionGroupMembersQuerySchema > -export const v2ListPermissionGroupWorkspacesQuerySchema = z - .object({ - search: v2SearchSchema.describe('Case-insensitive substring match against the workspace name.'), - ...v2SortFields(['name', 'id'] as const, { sortBy: 'name', sortOrder: 'asc' }), - ...v2PaginationFields({ description: 'Maximum organization workspaces to return per page.' }), - }) - .strict() -export type V2ListPermissionGroupWorkspacesQuery = z.output< - typeof v2ListPermissionGroupWorkspacesQuerySchema -> -const configPatchSchema = permissionGroupConfigSchema - .strict() - .describe( - 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.' - ) +const configPatchSchema = permissionGroupConfigSchema.strict() + export const v2CreatePermissionGroupBodySchema = createPermissionGroupBodySchema .safeExtend({ - config: configPatchSchema.optional(), + workspaceIds: createPermissionGroupBodySchema.shape.workspaceIds.describe( + 'Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.' + ), + config: configPatchSchema + .describe( + 'Permission restrictions to set. Omitted keys use the default permission configuration.' + ) + .optional(), }) .strict() .refine((body) => body.isDefault === true || Boolean(body.workspaceIds?.length), { @@ -135,7 +127,11 @@ export const v2CreatePermissionGroupBodySchema = createPermissionGroupBodySchema export type V2CreatePermissionGroupBody = z.input export const v2UpdatePermissionGroupBodySchema = updatePermissionGroupBodySchema .safeExtend({ - config: configPatchSchema.optional(), + config: configPatchSchema + .describe( + 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.' + ) + .optional(), }) .strict() .refine((body) => Object.values(body).some((value) => value !== undefined), { @@ -270,7 +266,7 @@ export const v2AddPermissionGroupMemberContract = defineRouteContract({ }) export const v2RemovePermissionGroupMemberContract = defineRouteContract({ method: 'DELETE', - path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]', params: v2PermissionGroupMemberParamsSchema, query: noInputSchema, response: { @@ -278,7 +274,7 @@ export const v2RemovePermissionGroupMemberContract = defineRouteContract({ schema: v2DataResponse( z .object({ - id: z.string().describe('Removed membership assignment identifier.'), + userId: z.string().describe('User whose membership assignment was removed.'), deleted: z.literal(true).describe('Whether the assignment was removed.'), }) .meta({ @@ -313,24 +309,3 @@ export const v2BulkAddPermissionGroupMembersContract = defineRouteContract({ ), }, }) -export const v2ListPermissionGroupWorkspacesContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/organizations/[organizationId]/permission-groups/workspaces', - params: v2PermissionGroupOrganizationParamsSchema, - query: v2ListPermissionGroupWorkspacesQuerySchema, - response: { - mode: 'json', - schema: v2CursorListResponse( - z - .object({ - id: z.string().describe('Workspace identifier.'), - name: z.string().describe('Workspace display name.'), - }) - .meta({ - id: 'V2PermissionGroupWorkspace', - title: 'Permission group workspace', - description: 'An organization workspace available for group targeting.', - }) - ), - }, -}) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 003ca40ce21..62f58e35f59 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -135,6 +135,19 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + v2CreateOrganizationInvitationContract, + v2GetOrganizationContract, + v2GetOrganizationInvitationContract, + v2ListOrganizationInvitationsContract, + v2ListOrganizationMembersContract, + v2ListOrganizationsContract, + v2ListOrganizationWorkspacesContract, + v2RemoveOrganizationMemberContract, + v2ResendOrganizationInvitationContract, + v2RevokeOrganizationInvitationContract, + v2UpdateOrganizationMemberContract, +} from '@/lib/api/contracts/v2/organizations' import { v2AddPermissionGroupMemberContract, v2BulkAddPermissionGroupMembersContract, @@ -143,7 +156,6 @@ import { v2GetPermissionGroupContract, v2ListPermissionGroupMembersContract, v2ListPermissionGroupsContract, - v2ListPermissionGroupWorkspacesContract, v2RemovePermissionGroupMemberContract, v2UpdatePermissionGroupContract, } from '@/lib/api/contracts/v2/permission-groups' @@ -637,6 +649,17 @@ export const V2_MCP_OPERATIONS = { 'Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.POST), }, + createOrganizationInvitation: { + contract: v2CreateOrganizationInvitationContract, + summary: 'Create Organization Invitation', + description: + 'Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/invitations/route').then( + (route) => route.POST + ), + }, createPermissionGroup: { contract: v2CreatePermissionGroupContract, summary: 'Create Permission Group', @@ -1196,6 +1219,26 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + getOrganization: { + contract: v2GetOrganizationContract, + summary: 'Get Organization', + description: + 'Get organization metadata and the acting user’s organization role. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/route').then((route) => route.GET), + }, + getOrganizationInvitation: { + contract: v2GetOrganizationInvitationContract, + summary: 'Get Organization Invitation', + description: + 'Get an invitation owned by the organization. Requires organization administrator access. The response excludes the acceptance token. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route').then( + (route) => route.GET + ), + }, getPermissionGroup: { contract: v2GetPermissionGroupContract, summary: 'Get Permission Group', @@ -1592,6 +1635,47 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/tools/route').then((route) => route.GET), }, + listOrganizationInvitations: { + contract: v2ListOrganizationInvitationsContract, + summary: 'List Organization Invitations', + description: + 'List invitations owned by the organization, including invitations with workspace grants. Requires organization administrator access. Expired invitations are reported without modifying them. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/invitations/route').then( + (route) => route.GET + ), + }, + listOrganizationMembers: { + contract: v2ListOrganizationMembersContract, + summary: 'List Organization Members', + description: + 'List organization members by name or email. Ordinary members must have access to the member directory; organization administrators retain access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/members/route').then( + (route) => route.GET + ), + }, + listOrganizations: { + contract: v2ListOrganizationsContract, + summary: 'List Organizations', + description: + 'List organizations the acting user belongs to. Organizations that disallow the calling credential are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/organizations/route').then((route) => route.GET), + }, + listOrganizationWorkspaces: { + contract: v2ListOrganizationWorkspacesContract, + summary: 'List Organization Workspaces', + description: + 'List active workspaces owned by the organization. Requires organization administrator access; does not require Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/workspaces/route').then( + (route) => route.GET + ), + }, listPermissionGroupMembers: { contract: v2ListPermissionGroupMembersContract, summary: 'List Permission Group Members', @@ -1614,17 +1698,6 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, - listPermissionGroupWorkspaces: { - contract: v2ListPermissionGroupWorkspacesContract, - summary: 'List Permission Group Workspaces', - description: - 'List organization workspaces available for permission-group scope selection with cursor pagination. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', - workspaceKeyUnsupported: true, - handler: () => - import('@/app/api/v2/organizations/[organizationId]/permission-groups/workspaces/route').then( - (route) => route.GET - ), - }, listSandboxes: { contract: v2ListSandboxesContract, summary: 'List Sandboxes', @@ -1944,15 +2017,26 @@ export const V2_MCP_OPERATIONS = { 'Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.PATCH), }, + removeOrganizationMember: { + contract: v2RemoveOrganizationMemberContract, + summary: 'Remove Organization Member', + description: + 'Remove a member and revoke their access to organization workspaces. Administrators may remove members; members may remove themselves. The organization owner cannot be removed. Owned organization resources are reassigned and the departing member’s sessions end. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/members/[userId]/route').then( + (route) => route.DELETE + ), + }, removePermissionGroupMember: { contract: v2RemovePermissionGroupMemberContract, summary: 'Remove Permission Group Member', description: - 'Remove a membership assignment. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Remove a member by user identifier. Removing the last member from an inherit group makes it govern everyone in its workspaces; a conflicting all-member group prevents the removal. Requires organization admin or owner access and active Access Control. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import( - '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]/route' + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route' ).then((route) => route.DELETE), }, renameFile: { @@ -1982,6 +2066,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/workflows/[workflowId]/state/route').then((route) => route.PUT), }, + resendOrganizationInvitation: { + contract: v2ResendOrganizationInvitationContract, + summary: 'Resend Organization Invitation', + description: + 'Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route' + ).then((route) => route.POST), + }, restoreFile: { contract: v2RestoreFileContract, summary: 'Restore File', @@ -2058,6 +2153,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + revokeOrganizationInvitation: { + contract: v2RevokeOrganizationInvitationContract, + summary: 'Revoke Organization Invitation', + description: + 'Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route').then( + (route) => route.DELETE + ), + }, revokeSkillEditor: { contract: v2RevokeSkillEditorContract, summary: 'Revoke Skill Editor', @@ -2279,6 +2385,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.PATCH), }, + updateOrganizationMember: { + contract: v2UpdateOrganizationMemberContract, + summary: 'Update Organization Member', + description: + 'Change a member’s organization role. Requires organization administrator access. The owner’s role and memberships managed by an identity provider cannot be changed here. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/members/[userId]/route').then( + (route) => route.PATCH + ), + }, updatePermissionGroup: { contract: v2UpdatePermissionGroupContract, summary: 'Update Permission Group', diff --git a/apps/sim/lib/api/server/organization-presenters.ts b/apps/sim/lib/api/server/organization-presenters.ts new file mode 100644 index 00000000000..1bcedf21a57 --- /dev/null +++ b/apps/sim/lib/api/server/organization-presenters.ts @@ -0,0 +1,60 @@ +import { organizationRoleSchema } from '@/lib/api/contracts/primitives' +import { v2OrganizationInvitationSchema } from '@/lib/api/contracts/v2/organizations' + +export function presentOrganization(organization: { + id: string + name: string + slug: string + logo: string | null + createdAt: Date + role: string +}) { + return { + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.logo, + role: organizationRoleSchema.parse(organization.role), + createdAt: organization.createdAt.toISOString(), + } +} + +export function presentOrganizationMember(member: { + userId: string + userName: string + userEmail: string + role: string + createdAt: Date +}) { + return { + userId: member.userId, + name: member.userName, + email: member.userEmail, + role: organizationRoleSchema.parse(member.role), + joinedAt: member.createdAt.toISOString(), + } +} + +export function presentOrganizationInvitation(invitation: { + id: string + organizationId: string | null + email: string + role: string + kind: string + membershipIntent: string + status: string + createdAt: Date + expiresAt: Date +}) { + return v2OrganizationInvitationSchema.parse({ + id: invitation.id, + organizationId: invitation.organizationId, + email: invitation.email, + role: invitation.role, + kind: invitation.kind, + membershipIntent: invitation.membershipIntent, + status: invitation.status, + createdAt: invitation.createdAt.toISOString(), + expiresAt: invitation.expiresAt.toISOString(), + }) +} diff --git a/apps/sim/lib/api/server/routes/organizations.ts b/apps/sim/lib/api/server/routes/organizations.ts new file mode 100644 index 00000000000..248ed80f6a5 --- /dev/null +++ b/apps/sim/lib/api/server/routes/organizations.ts @@ -0,0 +1,69 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes/internal-json-route' +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' +import { isRetryableTransactionError } from '@/lib/db/transaction' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { CAPABILITY_RULES, capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' + +export const internalOrganizationErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (error instanceof OrganizationMembershipNotFoundError) + return internalErrorResponse(403, { error: 'Forbidden - Not a member of this organization' }) + if (error instanceof ForbiddenOperationError) + return internalErrorResponse(403, { + error: error.message, + details: { code: error.detailCode }, + }) + if (error instanceof WorkspaceInvitationError) + return internalErrorResponse(error.status, { + error: error.message, + ...(error.email ? { email: error.email } : {}), + ...(error.upgradeRequired === undefined ? {} : { upgradeRequired: error.upgradeRequired }), + }) + if (error instanceof InvitationsNotAllowedError) + return internalErrorResponse(403, { + error: capabilityRefusal('invitations.send'), + details: { code: CAPABILITY_RULES['invitations.send'].detailCode }, + }) + if (isRetryableTransactionError(error)) + return internalErrorResponse(409, { error: 'The organization is busy; retry in a moment' }) + return null + } +) + +export const v2OrganizationErrorPolicy: V2ErrorPolicy = { + render(error) { + if (error instanceof InvitationsNotAllowedError) + return v2Error('FORBIDDEN', capabilityRefusal('invitations.send'), { + details: { code: CAPABILITY_RULES['invitations.send'].detailCode }, + }) + if (error instanceof WorkspaceInvitationError) { + if (error.status >= 500) + return v2Error( + 'SERVICE_UNAVAILABLE', + 'Invitation delivery is temporarily unavailable. Check the invitation status before retrying.' + ) + if (error.status === 409) return v2Error('CONFLICT', error.message) + if (error.status === 403) + return v2Error('FORBIDDEN', error.message, { + details: { + code: error.upgradeRequired + ? 'ORGANIZATION_PLAN_REQUIRED' + : 'ORGANIZATION_ADMIN_REQUIRED', + }, + }) + return v2Error('BAD_REQUEST', error.message) + } + if (isRetryableTransactionError(error)) + return v2Error('SERVICE_UNAVAILABLE', 'The organization is busy; retry in a moment') + return v2CaughtOrchestrationError(error) + }, +} diff --git a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts index c68b4662235..1550518a43a 100644 --- a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts +++ b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts @@ -84,4 +84,49 @@ describe('external organization access removal', () => { ).toBe(true) } }) + it.each(['40001', '40P01', '55P03'])( + 'preserves retryable transaction failure %s', + async (code) => { + const failure = Object.assign(new Error('retry transaction'), { code }) + dbChainMockFns.transaction.mockRejectedValueOnce(failure) + await expect( + removeExternalUserFromOrganizationWorkspaces({ + organizationId: 'org-1', + userId: 'external', + }) + ).rejects.toBe(failure) + queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) + dbChainMockFns.transaction.mockRejectedValueOnce(failure) + await expect( + removeUserFromOrganization({ + organizationId: 'org-1', + userId: 'target', + memberId: 'membership', + }) + ).rejects.toBe(failure) + } + ) + + it('rejects an actor demoted before the locked removal', async () => { + queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) + queueTableRows(member, [{ id: 'actor-membership', role: 'member' }]) + await expect( + removeUserFromOrganization({ + organizationId: 'org-1', + userId: 'target', + memberId: 'membership', + actorUserId: 'actor', + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('reports membership appearing during external removal as a conflict', async () => { + queueTableRows(member, []) + queueTableRows(member, [{ id: 'new-membership' }]) + await expect( + removeExternalUserFromOrganizationWorkspaces({ organizationId: 'org-1', userId: 'external' }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index d4bd323b3cb..ecbc649a7bd 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -48,10 +48,13 @@ import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' +import { isRetryableTransactionError } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { requireMemberManagementAuthority } from '@/lib/organizations/members/authority' import { revokePersonalApiKeysTx, revokeUserSessionsTx, @@ -481,6 +484,10 @@ export interface RemoveMemberParams { revokePersonalApiKeys?: boolean /** The caller's own session token, kept alive when a member removes themselves. */ spareSessionToken?: string + /** Verified session row to preserve during a self-removal. */ + spareSessionId?: string + /** Acting member whose management authority is rechecked under the mutation lock. */ + actorUserId?: string /** * Only remove the member when they hold no remaining permission on any of the * org's workspaces, evaluated atomically under the membership lock. Used by @@ -1279,6 +1286,8 @@ export async function removeUserFromOrganization( requireNoOrgWorkspaceAccess = false, revokePersonalApiKeys = false, spareSessionToken, + spareSessionId, + actorUserId, } = params const billingActions = { @@ -1311,6 +1320,8 @@ export async function removeUserFromOrganization( const result = await withInvitationSafeOrganizationAccessMutation( { userId, organizationId, scope: 'all' }, async (tx, { workspaceIds, invitationIds }) => { + if (actorUserId) + await requireMemberManagementAuthority(tx, organizationId, actorUserId, userId) if (requireNoOrgWorkspaceAccess && workspaceIds.length > 0) { const [remainingAccess] = await tx .select({ id: permissions.id }) @@ -1335,8 +1346,9 @@ export async function removeUserFromOrganization( .returning({ id: member.id }) if (deletedMember.length === 0) { - throw new Error( - 'Member could not be removed — they may have been promoted to owner concurrently' + throw new OrchestrationError( + 'conflict', + 'The membership changed before removal. Refresh and try again.' ) } @@ -1391,6 +1403,7 @@ export async function removeUserFromOrganization( userId, organizationId, ...(spareSessionToken ? { spareSessionToken } : {}), + ...(spareSessionId ? { spareSessionId } : {}), }) if (revokePersonalApiKeys) await revokePersonalApiKeysTx(tx, { userId }) await endDirectoryMembershipTx(tx, { userId, organizationId }) @@ -1516,6 +1529,7 @@ export async function removeUserFromOrganization( return { success: true, removed: true, billingActions } } catch (error) { + if (error instanceof OrchestrationError || isRetryableTransactionError(error)) throw error if (error instanceof WorkspaceBillingAccountRemovalError) { return { success: false, error: error.message, billingActions } } @@ -1537,6 +1551,7 @@ export async function removeUserFromOrganization( export async function removeExternalUserFromOrganizationWorkspaces(params: { userId: string organizationId: string + actorUserId?: string }): Promise { const { userId, organizationId } = params @@ -1566,12 +1581,18 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { } = await withInvitationSafeOrganizationAccessMutation( { userId, organizationId, scope: 'external' }, async (tx, { workspaceIds, invitationIds }) => { + if (params.actorUserId) + await requireMemberManagementAuthority(tx, organizationId, params.actorUserId) const [currentMember] = await tx .select({ id: member.id }) .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) - if (currentMember) throw new Error('User is an organization member') + if (currentMember) + throw new OrchestrationError( + 'conflict', + 'User is now an organization member. Refresh before removing them.' + ) await setOrgMemberUsageLimit(organizationId, userId, null, undefined, tx) @@ -1686,6 +1707,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { pendingInvitationsCancelled, } } catch (error) { + if (error instanceof OrchestrationError || isRetryableTransactionError(error)) throw error if (error instanceof WorkspaceBillingAccountRemovalError) { return { success: false, diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 14ae6b73196..c252b9ae76e 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -38,6 +38,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/logs.mdx', 'cli/mcp-servers.mdx', 'cli/meta.mdx', + 'cli/organizations.mdx', 'cli/output.mdx', 'cli/permission-groups.mdx', 'cli/profiles.mdx', diff --git a/apps/sim/lib/core/application/authorized-organization-use-case.ts b/apps/sim/lib/core/application/authorized-organization-use-case.ts new file mode 100644 index 00000000000..55d01cbf15d --- /dev/null +++ b/apps/sim/lib/core/application/authorized-organization-use-case.ts @@ -0,0 +1,77 @@ +import type { Principal } from '@sim/auth/principal' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { + authorizeOrganizationOperation, + type OrganizationMembershipContext, +} from '@/lib/core/application/organization-authorization' +import type { OrganizationOperation } from '@/lib/core/application/organization-operation' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface OrganizationUseCaseContext { + principal: Principal + input: I + context: OrganizationMembershipContext + request?: OrchestrationRequestContext +} + +/** The organization counterpart to the shared workspace authorization and audit lifecycle. */ +export function defineAuthorizedOrganizationUseCase< + const O extends OrganizationOperation, + I extends { organizationId: string }, + R, +>(definition: { + operation: O + authorizeResource?(args: OrganizationUseCaseContext): void | Promise + execute(args: OrganizationUseCaseContext): Promise + projectAudit?( + args: OrganizationUseCaseContext & { result: NoInfer } + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: OrganizationUseCaseContext & { result: NoInfer }): void | Promise +}): AuthorizingUseCase { + async function authorizePhase(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise> { + const context = await authorizeOrganizationOperation( + args.principal, + definition.operation, + args.input + ) + const executionContext = { ...args, context } + await definition.authorizeResource?.(executionContext) + return executionContext + } + + return { + operation: definition.operation, + async authorize(args) { + await authorizePhase(args) + }, + async execute(args) { + const executionContext = await authorizePhase(args) + return runWithOutboundOrganization(executionContext.context.organizationId, async () => { + const result = await definition.execute(executionContext) + const resultContext = { ...executionContext, result } + const audit = definition.projectAudit?.(resultContext) + if (audit !== undefined) { + recordProjectedUseCaseAuditEntries( + definition.operation, + null, + args.principal, + args.request, + Array.isArray(audit) ? audit : [audit], + executionContext.context.organizationId + ) + } + await definition.afterSuccess?.(resultContext) + return result + }) + }, + } +} diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index 64fa5b7d63b..45ca8eb2557 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm' import type { OrganizationRole } from '@/lib/api/contracts/primitives' import { organizationRoleSchema } from '@/lib/api/contracts/primitives' import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' import type { OperationDeclarableCapability } from '@/lib/core/application/operation' import type { OrganizationOperation } from '@/lib/core/application/organization-operation' @@ -20,6 +21,12 @@ import { refuseCapability } from '@/lib/permission-groups/capabilities' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +export class OrganizationMembershipNotFoundError extends OrchestrationError { + constructor() { + super('not_found', 'Organization not found') + } +} + export interface OrganizationAuthorizationContext { organizationId: string } @@ -69,9 +76,12 @@ async function requireOrganizationSubjectMembership( .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) const [membership] = options.forUpdate ? await query.for('update').limit(1) : await query.limit(1) const parsedRole = organizationRoleSchema.safeParse(membership?.role) - if (!parsedRole.success) throw new OrchestrationError('not_found', 'Organization not found') + if (!parsedRole.success) throw new OrganizationMembershipNotFoundError() if (minimumRole === 'admin' && !isOrgAdminRole(parsedRole.data)) { - throw new OrchestrationError('forbidden', 'Organization administrator access is required') + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization administrator access is required' + ) } const config = capability === 'none' && !userCredential diff --git a/apps/sim/lib/invitations/application/authorize-mutation.ts b/apps/sim/lib/invitations/application/authorize-mutation.ts new file mode 100644 index 00000000000..58a3bc2b626 --- /dev/null +++ b/apps/sim/lib/invitations/application/authorize-mutation.ts @@ -0,0 +1,104 @@ +import type { Principal } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import { + authorizeOrganizationOperation, + OrganizationMembershipNotFoundError, +} from '@/lib/core/application/organization-authorization' +import { + authorizeWorkspaceOperation, + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + PrincipalKindAuthorizationError, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + invitationAuthorityOperations, + invitationOperations, +} from '@/lib/invitations/application/operations' +import { getInvitationById } from '@/lib/invitations/core' +import { loadWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface InvitationMutationInput { + invitationId: string + assertedOrganizationId?: string + workspaceId?: string +} + +/** Public credentials administer an asserted organization; internal sessions retain workspace authority. */ +export async function authorizeInvitationMutation( + principal: Principal, + input: InvitationMutationInput, + action: 'resend' | 'revoke' +) { + const operation = invitationOperations[action] + if ( + principal.kind !== 'session' && + principal.kind !== 'personal_api_key' && + principal.kind !== 'oauth_access_token' + ) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + requireOAuthOperationScope(principal, operation) + if (principal.kind !== 'session' && !input.assertedOrganizationId) + throw new Error('Credential invitation administration requires an asserted organization') + const invitation = await getInvitationById(input.invitationId) + if ( + !invitation || + (input.assertedOrganizationId !== undefined && + invitation.organizationId !== input.assertedOrganizationId) + ) + throw new OrchestrationError('not_found', 'Invitation not found') + if (invitation.organizationId) { + try { + await authorizeOrganizationOperation(principal, invitationAuthorityOperations.organization, { + organizationId: invitation.organizationId, + }) + return { invitation, actorUserId: principal.userId } + } catch (error) { + if ( + input.assertedOrganizationId !== undefined || + !( + error instanceof OrganizationMembershipNotFoundError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'ORGANIZATION_ADMIN_REQUIRED') + ) + ) + throw error + } + } + if (principal.kind !== 'session') + throw new Error('Workspace invitation authority requires a session') + const grants = input.workspaceId + ? invitation.grants.filter((grant) => grant.workspaceId === input.workspaceId) + : invitation.grants + let authorized = 0 + for (const grant of grants) { + const context = await loadWorkspaceApplicationContext(grant.workspaceId, { + includeArchived: true, + }) + if (!context) continue + try { + await authorizeWorkspaceOperation(principal, invitationAuthorityOperations.workspace, context) + authorized++ + if (action === 'resend') break + } catch (error) { + if ( + !( + error instanceof InsufficientWorkspacePermissionsError || + error instanceof NoWorkspaceAccessError + ) + ) + throw error + } + } + if (authorized === 0 || (action === 'revoke' && authorized !== grants.length)) + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + input.workspaceId + ? 'You need admin permissions on that workspace to revoke its invitation' + : action === 'revoke' && invitation.grants.length > 1 + ? 'This invitation spans several workspaces. Revoke it from a workspace you administer, or ask an organization admin.' + : `Only an organization or workspace admin can ${action === 'resend' ? 'resend' : 'cancel'} this invitation` + ) + return { invitation, actorUserId: principal.userId } +} diff --git a/apps/sim/lib/invitations/application/mutations.test.ts b/apps/sim/lib/invitations/application/mutations.test.ts new file mode 100644 index 00000000000..ef650e1bc5f --- /dev/null +++ b/apps/sim/lib/invitations/application/mutations.test.ts @@ -0,0 +1,164 @@ +/** @vitest-environment node */ +import { recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invitation: vi.fn(), + org: vi.fn(), + workspace: vi.fn(), + context: vi.fn(), + resend: vi.fn(), + revoke: vi.fn(), +})) +vi.mock('@sim/audit', async (original) => ({ + ...(await original()), + recordAudit: vi.fn(), +})) +vi.mock('@/lib/core/application/organization-authorization', async (original) => ({ + ...(await original()), + authorizeOrganizationOperation: mocks.org, +})) +vi.mock('@/lib/core/application/workspace-authorization', async (original) => ({ + ...(await original()), + authorizeWorkspaceOperation: mocks.workspace, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadWorkspaceApplicationContext: mocks.context, +})) +vi.mock('@/lib/invitations/core', () => ({ getInvitationById: mocks.invitation })) +vi.mock('@/lib/invitations/mutation-manager', () => ({ + resendInvitationRecord: mocks.resend, + revokeInvitationRecord: mocks.revoke, +})) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resendInvitation, revokeInvitation } from '@/lib/invitations/application/mutations' + +const session: Principal = { kind: 'session', userId: 'actor', sessionId: 'session' } +const key: Principal = { kind: 'personal_api_key', userId: 'actor', keyId: 'key' } +const oauth: Principal = { + kind: 'oauth_access_token', + userId: 'actor', + tokenId: 'token', + clientId: 'client', + scopes: ['api:read', 'api:write'], + expiresAt: new Date('2099-01-01'), +} +const inv = { + id: 'invite', + kind: 'workspace', + organizationId: 'org', + email: 'person@example.com', + role: 'member', + membershipIntent: 'internal', + grants: [{ workspaceId: 'one' }, { workspaceId: 'two' }], +} +const input = { invitationId: 'invite' } +beforeEach(() => { + vi.resetAllMocks() + mocks.invitation.mockResolvedValue(inv) + mocks.context.mockImplementation(async (workspaceId) => ({ + workspaceId, + workspaceOrganizationId: 'org', + allowPersonalApiKeys: true, + })) + mocks.resend.mockResolvedValue(inv) + mocks.revoke.mockResolvedValue({ success: true, invitation: inv, invitationCancelled: true }) +}) + +describe('shared invitation administration', () => { + it('retains any-workspace resend, every-workspace revoke, and scoped revocation authority', async () => { + mocks.org.mockRejectedValue( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) + mocks.workspace.mockImplementation(async (_principal, _operation, context) => { + if (context.workspaceId === 'two') throw new InsufficientWorkspacePermissionsError() + }) + await resendInvitation.execute({ principal: session, input }) + expect(mocks.resend).toHaveBeenCalledOnce() + await expect(revokeInvitation.execute({ principal: session, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.revoke).not.toHaveBeenCalled() + mocks.revoke.mockResolvedValue({ success: true, invitation: inv, invitationCancelled: false }) + await revokeInvitation.execute({ principal: session, input: { ...input, workspaceId: 'one' } }) + expect(mocks.revoke).toHaveBeenCalledWith({ + ...input, + workspaceId: 'one', + actorUserId: 'actor', + }) + expect(recordAudit).toHaveBeenLastCalledWith( + expect.objectContaining({ + workspaceId: 'one', + resourceId: 'one', + metadata: expect.objectContaining({ invitationCancelled: false }), + }) + ) + }) + + it.each([session, key, oauth])( + 'never borrows workspace authority for an asserted organization ($kind)', + async (principal) => { + mocks.org.mockRejectedValue( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) + await expect( + resendInvitation.execute({ principal, input: { ...input, assertedOrganizationId: 'org' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.workspace).not.toHaveBeenCalled() + expect(mocks.resend).not.toHaveBeenCalled() + } + ) + + it.each([ + new ForbiddenOperationError('PERMISSION_GROUP_CAPABILITY_BLOCKED', 'Withheld'), + new Error('Database unavailable'), + ])('does not fall back after credential/capability or infrastructure refusal', async (error) => { + mocks.org.mockRejectedValue(error) + await expect(resendInvitation.execute({ principal: session, input })).rejects.toBe(error) + expect(mocks.workspace).not.toHaveBeenCalled() + }) + + it('conceals asserted organization mismatch before authorization', async () => { + await expect( + resendInvitation.execute({ + principal: key, + input: { ...input, assertedOrganizationId: 'other' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.org).not.toHaveBeenCalled() + }) + + it.each([session, key, oauth])( + 'attributes successful mutations to the real $kind actor and canonical workspace', + async (principal) => { + await resendInvitation.execute({ + principal, + input: { ...input, assertedOrganizationId: 'org' }, + }) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'actor', + workspaceId: 'one', + resourceId: 'one', + resourceType: 'workspace', + metadata: expect.objectContaining({ + actor: expect.objectContaining({ kind: principal.kind }), + operation: 'invitations.resend', + }), + }) + ) + } + ) + + it('does not audit a resend rejected after email delivery', async () => { + mocks.resend.mockRejectedValue(new OrchestrationError('conflict', 'Invitation changed')) + await expect(resendInvitation.execute({ principal: session, input })).rejects.toMatchObject({ + code: 'conflict', + }) + expect(recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/invitations/application/mutations.ts b/apps/sim/lib/invitations/application/mutations.ts new file mode 100644 index 00000000000..0776e9f8b9d --- /dev/null +++ b/apps/sim/lib/invitations/application/mutations.ts @@ -0,0 +1,120 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { + authorizeInvitationMutation, + type InvitationMutationInput, +} from '@/lib/invitations/application/authorize-mutation' +import { invitationOperations } from '@/lib/invitations/application/operations' +import { resendInvitationRecord, revokeInvitationRecord } from '@/lib/invitations/mutation-manager' + +export const resendInvitation: OperationUseCase< + typeof invitationOperations.resend, + Omit, + Awaited> +> = { + operation: invitationOperations.resend, + async execute({ principal, input, request }) { + const { invitation, actorUserId } = await authorizeInvitationMutation( + principal, + input, + 'resend' + ) + return runWithOutboundOrganization(invitation.organizationId, async () => { + const result = await resendInvitationRecord({ + invitation, + actorUserId, + assertedOrganizationId: input.assertedOrganizationId, + }) + recordProjectedUseCaseAuditEntries( + invitationOperations.resend, + invitation.grants[0]?.workspaceId ?? null, + principal, + request, + [ + { + action: + invitation.kind === 'workspace' + ? AuditAction.INVITATION_RESENT + : AuditAction.ORG_INVITATION_RESENT, + resourceType: + invitation.kind === 'workspace' + ? AuditResourceType.WORKSPACE + : AuditResourceType.ORGANIZATION, + resourceId: + invitation.kind === 'workspace' + ? (invitation.grants[0]?.workspaceId ?? invitation.id) + : (invitation.organizationId ?? invitation.id), + description: `Resent ${invitation.kind} invitation to ${invitation.email}`, + metadata: { + invitationId: invitation.id, + targetEmail: invitation.email, + targetRole: invitation.role, + kind: invitation.kind, + membershipIntent: invitation.membershipIntent, + }, + }, + ], + invitation.organizationId ?? undefined + ) + return result + }) + }, +} + +export const revokeInvitation: OperationUseCase< + typeof invitationOperations.revoke, + InvitationMutationInput, + Awaited> +> = { + operation: invitationOperations.revoke, + async execute({ principal, input, request }) { + const { actorUserId } = await authorizeInvitationMutation(principal, input, 'revoke') + const result = await revokeInvitationRecord({ ...input, actorUserId }) + const inv = result.invitation + const workspaceId = input.workspaceId ?? inv.grants[0]?.workspaceId ?? null + recordProjectedUseCaseAuditEntries( + invitationOperations.revoke, + workspaceId, + principal, + request, + [ + input.workspaceId + ? { + action: AuditAction.INVITATION_REVOKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: input.workspaceId, + description: `Revoked ${inv.email}'s pending invitation to this workspace`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + workspaceId: input.workspaceId, + invitationCancelled: result.invitationCancelled, + }, + } + : { + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_REVOKED + : AuditAction.ORG_INVITATION_REVOKED, + resourceType: + inv.kind === 'workspace' + ? AuditResourceType.WORKSPACE + : AuditResourceType.ORGANIZATION, + resourceId: + inv.kind === 'workspace' ? (workspaceId ?? inv.id) : (inv.organizationId ?? inv.id), + description: `Cancelled ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + }, + ], + inv.organizationId ?? undefined + ) + return result + }, +} diff --git a/apps/sim/lib/invitations/application/operations.ts b/apps/sim/lib/invitations/application/operations.ts new file mode 100644 index 00000000000..5fe6de915ad --- /dev/null +++ b/apps/sim/lib/invitations/application/operations.ts @@ -0,0 +1,50 @@ +import { defineOperation } from '@/lib/core/application/operation' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +export const invitationOperations = { + sendBatch: defineOperation({ + id: 'invitations.send_batch', + capability: 'invitations.send', + principalKinds: ['session'], + }), + resend: defineOperation({ + id: 'invitations.resend', + capability: 'invitations.send', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: withdrawing access remains available when sending invitations is disabled. + */ + revoke: defineOperation({ + id: 'invitations.revoke', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), +} as const + +/** Authority branches are fixed; invitation admission capabilities are checked after authority. */ +export const invitationAuthorityOperations = { + /** + * permission-group-exempt: resend checks the admission organization and every grant; revoke needs no send capability. + */ + organization: defineOrganizationOperation({ + id: 'invitations.organization.authorize', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: this session-only branch checks authority; resend separately checks every admission scope. + */ + workspace: defineWorkspaceOperation({ + id: 'invitations.workspace.authorize', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts index 689f4755205..9f23dfde5f2 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts @@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/invitations/organization-invitations', () => ({ prepareOrganizationInvitationContext: mocks.orgContext, - createOrganizationInvitation: mocks.orgSend, +})) +vi.mock('@/lib/organizations/application/invitations', () => ({ + createOrganizationInvitation: { execute: mocks.orgSend }, })) vi.mock('@/lib/invitations/workspace-invitations', () => ({ prepareWorkspaceInvitationContext: mocks.workspaceContext, @@ -105,7 +107,12 @@ describe('invitation batch application boundary', () => { inviterEmail: 'admin@example.com', }) expect(mocks.workspaceContext).not.toHaveBeenCalled() - expect(mocks.orgSend).toHaveBeenCalledWith(expect.objectContaining({ role: 'member' })) + expect(mocks.orgSend).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { organizationId: 'org-target', email: 'person@example.com', role: 'member' }, + }) + ) }) it('keeps workspace invitations on the existing per-workspace authorization path', async () => { diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.ts b/apps/sim/lib/invitations/application/send-invitation-batch.ts index ecd78f9497f..2fe22dc217e 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.ts @@ -5,15 +5,12 @@ import { normalizeEmail } from '@sim/utils/string' import { eq } from 'drizzle-orm' import { assertOperationPrincipal, - defineOperation, ForbiddenOperationError, type OperationUseCase, } from '@/lib/core/application' +import { invitationOperations } from '@/lib/invitations/application/operations' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' -import { - createOrganizationInvitation, - prepareOrganizationInvitationContext, -} from '@/lib/invitations/organization-invitations' +import { prepareOrganizationInvitationContext } from '@/lib/invitations/organization-invitations' import { createWorkspaceInvitation, type InvitationMembership, @@ -21,18 +18,11 @@ import { WorkspaceInvitationError, type WorkspaceInvitationResult, } from '@/lib/invitations/workspace-invitations' +import { createOrganizationInvitation } from '@/lib/organizations/application/invitations' import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('InvitationBatch') -export const invitationOperations = { - sendBatch: defineOperation({ - id: 'invitations.send_batch', - capability: 'invitations.send', - principalKinds: ['session'], - }), -} as const - export interface SendInvitationBatchInput { workspaceIds: string[] organizationId?: string @@ -129,10 +119,13 @@ export const sendInvitationBatch: OperationUseCase< seenEmails.add(normalizedEmail) try { const invitation = organizationContext - ? await createOrganizationInvitation({ - context: organizationContext, - email, - role: input.membership === 'admin' ? 'admin' : 'member', + ? await createOrganizationInvitation.execute({ + principal, + input: { + organizationId: organizationContext.organizationId, + email, + role: input.membership === 'admin' ? 'admin' : 'member', + }, request, }) : workspaceContext diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 2316d269116..3ac19c61db5 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -35,6 +35,7 @@ import { import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { isPro, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import type { DbOrTx } from '@/lib/db/types' @@ -185,6 +186,31 @@ async function lockWorkspaceAdminAuthority( return lockOrganizationAdminAuthority(tx, actorId, ws.organizationId) } +/** Rechecks resend authority while the invitation and all grant workspaces are locked. */ +export async function requireInvitationResendAuthority( + tx: DbOrTx, + invitation: InvitationWithGrants, + actorId: string, + assertedOrganizationId?: string +): Promise { + if ( + invitation.organizationId && + (await lockOrganizationAdminAuthority(tx, actorId, invitation.organizationId)) + ) + return + if (assertedOrganizationId === undefined) { + for (const workspaceId of [ + ...new Set(invitation.grants.map((grant) => grant.workspaceId)), + ].sort()) { + if (await lockWorkspaceAdminAuthority(tx, actorId, workspaceId)) return + } + } + throw new ForbiddenOperationError( + assertedOrganizationId ? 'ORGANIZATION_ADMIN_REQUIRED' : 'INSUFFICIENT_WORKSPACE_ROLE', + 'Administrator access is required to resend this invitation' + ) +} + async function hydrateInvitation( row: typeof invitation.$inferSelect, executor: DbOrTx = db @@ -1721,6 +1747,7 @@ export type AuthorizedInvitationRevocationResult = export async function revokeInvitationAsAdmin(input: { actorId: string invitationId: string + organizationId?: string workspaceId?: string }): Promise { return db.transaction(async (tx): Promise => { @@ -1728,13 +1755,18 @@ export async function revokeInvitationAsAdmin(input: { lockCurrentGrantWorkspaces: input.workspaceId === undefined, additionalWorkspaceIds: input.workspaceId ? [input.workspaceId] : [], }) - if (!inv) return { success: false, kind: 'not-found' } - if (inv.status !== 'pending') return { success: false, kind: 'not-pending' } + if (!inv || (input.organizationId !== undefined && inv.organizationId !== input.organizationId)) + return { success: false, kind: 'not-found' } + if (inv.status !== 'pending' || inv.expiresAt.getTime() <= Date.now()) + return { success: false, kind: 'not-pending' } const isOrganizationAdmin = inv.organizationId ? await lockOrganizationAdminAuthority(tx, input.actorId, inv.organizationId) : false + if (input.organizationId !== undefined && !isOrganizationAdmin) + return { success: false, kind: 'whole-forbidden' } + if (input.workspaceId) { if (!inv.grants.some((grant) => grant.workspaceId === input.workspaceId)) { return { success: false, kind: 'grant-not-found' } diff --git a/apps/sim/lib/invitations/mutation-manager.ts b/apps/sim/lib/invitations/mutation-manager.ts new file mode 100644 index 00000000000..d8f9ba09f00 --- /dev/null +++ b/apps/sim/lib/invitations/mutation-manager.ts @@ -0,0 +1,155 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type InvitationWithGrants, + resolveInvitationAdmissionOrganizationId, + revokeInvitationAsAdmin, +} from '@/lib/invitations/core' +import { + persistInvitationResend, + prepareInvitationResend, + sendInvitationEmail, +} from '@/lib/invitations/send' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' + +export async function resendInvitationRecord(input: { + invitation: InvitationWithGrants + actorUserId: string + assertedOrganizationId?: string +}) { + const inv = input.invitation + if (inv.status !== 'pending' || inv.expiresAt.getTime() <= Date.now()) + throw new OrchestrationError( + 'conflict', + 'Can only resend unexpired pending invitations. Create a new invitation after expiration.' + ) + /** permission-group-enforced: invitations.send — resend rechecks organization admission and every workspace grant. */ + const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) + if (admissionOrganizationId) + await validateInvitationsAllowed(input.actorUserId, { organizationId: admissionOrganizationId }) + for (const grant of inv.grants) { + await validateInvitationsAllowed(input.actorUserId, { workspaceId: grant.workspaceId }) + const details = await getWorkspaceWithOwner(grant.workspaceId) + if (!details) + throw new OrchestrationError( + 'conflict', + 'Invitation references a workspace that no longer exists' + ) + const policy = await getWorkspaceInvitePolicy(details) + if (!policy.allowed) + throw new WorkspaceInvitationError({ + status: 403, + message: policy.reason ?? 'Invites are no longer allowed on this workspace', + upgradeRequired: policy.upgradeRequired, + }) + } + if ( + isBillingEnabled && + inv.kind === 'organization' && + inv.grants.length === 0 && + inv.organizationId + ) { + const subscription = await getOrganizationSubscription(inv.organizationId) + if ( + !subscription || + !hasUsableSubscriptionStatus(subscription.status) || + (!isTeam(subscription.plan) && !isEnterprise(subscription.plan)) + ) + throw new WorkspaceInvitationError({ + status: 403, + message: 'Invites are no longer allowed on this organization', + upgradeRequired: true, + }) + } + const [actor] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, input.actorUserId)) + .limit(1) + if (!actor) throw new OrchestrationError('not_found', 'Authenticated user not found') + const resend = await prepareInvitationResend({ + invitationId: inv.id, + currentToken: inv.token, + rotateToken: true, + }) + const delivered = await sendInvitationEmail({ + invitationId: inv.id, + token: resend.tokenForEmail, + kind: inv.kind, + email: inv.email, + inviterName: actor.name || actor.email || 'A user', + organizationId: inv.organizationId, + organizationRole: inv.role === 'admin' ? 'admin' : 'member', + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + permission: grant.permission, + })), + }) + if (!delivered.success) + throw new WorkspaceInvitationError({ + status: 502, + message: delivered.error || 'Failed to send invitation email', + }) + await persistInvitationResend({ + invitationId: inv.id, + nextToken: resend.nextToken, + nextExpiresAt: resend.nextExpiresAt, + expectedOrganizationId: input.assertedOrganizationId, + expectedUpdatedAt: inv.updatedAt, + actorUserId: input.actorUserId, + }) + return { + id: inv.id, + organizationId: inv.organizationId, + email: inv.email, + role: inv.role, + kind: inv.kind, + membershipIntent: inv.membershipIntent, + status: inv.status, + createdAt: inv.createdAt, + expiresAt: resend.nextExpiresAt, + grants: inv.grants, + } +} + +export async function revokeInvitationRecord(input: { + invitationId: string + actorUserId: string + assertedOrganizationId?: string + workspaceId?: string +}) { + const result = await revokeInvitationAsAdmin({ + actorId: input.actorUserId, + invitationId: input.invitationId, + organizationId: input.assertedOrganizationId, + workspaceId: input.workspaceId, + }) + if (!result.success) { + if (result.kind === 'not-found') + throw new OrchestrationError('not_found', 'Invitation not found') + if (result.kind === 'scoped-forbidden' || result.kind === 'whole-forbidden') + throw new ForbiddenOperationError( + input.assertedOrganizationId + ? 'ORGANIZATION_ADMIN_REQUIRED' + : 'INSUFFICIENT_WORKSPACE_ROLE', + 'Administrator access is required to revoke this invitation' + ) + if (result.kind === 'grant-not-found') + throw new OrchestrationError( + 'validation', + 'Invitation does not grant access to that workspace' + ) + throw new OrchestrationError('conflict', 'Can only revoke unexpired pending invitations') + } + return result +} diff --git a/apps/sim/lib/invitations/organization-invitations.test.ts b/apps/sim/lib/invitations/organization-invitations.test.ts index 0166facbdf8..4d51c014bcd 100644 --- a/apps/sim/lib/invitations/organization-invitations.test.ts +++ b/apps/sim/lib/invitations/organization-invitations.test.ts @@ -84,6 +84,7 @@ beforeEach(() => { return { invitationId: 'invite-new', token: 'synthetic-token', + expiresAt: new Date('2026-09-14T12:00:00Z'), created: true, grants: [], mutationUpdatedAt: revision, @@ -145,9 +146,7 @@ describe('organization-only invitations', () => { expect(mocks.send).toHaveBeenCalledWith( expect.objectContaining({ kind: 'organization', grants: [], email: 'person@example.com' }) ) - expect(auditMock.recordAudit).toHaveBeenCalledWith( - expect.objectContaining({ actorId: 'admin-user', resourceId: 'org-target' }) - ) + expect(auditMock.recordAudit).not.toHaveBeenCalled() }) it('refuses an admin whose role changed while sending', async () => { diff --git a/apps/sim/lib/invitations/organization-invitations.ts b/apps/sim/lib/invitations/organization-invitations.ts index b2634ccf9a7..feaffaf8e32 100644 --- a/apps/sim/lib/invitations/organization-invitations.ts +++ b/apps/sim/lib/invitations/organization-invitations.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { foldedEmail, member, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -14,7 +13,6 @@ import { } from '@/lib/billing/organizations/membership' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { cancelPendingInvitation, createPendingInvitation, @@ -66,13 +64,20 @@ export async function createOrganizationInvitation({ context, email, role, - request, }: { context: OrganizationInvitationContext email: string role: 'member' | 'admin' - request?: OrchestrationRequestContext -}): Promise { +}): Promise< + WorkspaceInvitationResult & { + organizationId: string + role: 'member' | 'admin' + kind: 'organization' + status: 'pending' + createdAt: Date + expiresAt: Date + } +> { const normalizedEmail = normalizeEmail(email) const validation = quickValidateEmail(normalizedEmail) if (!validation.isValid) { @@ -187,25 +192,14 @@ export async function createOrganizationInvitation({ email: normalizedEmail, }) } - recordAudit({ - actorId: context.inviterId, - actorName: context.inviterName, - actorEmail: context.inviterEmail, - action: AuditAction.MEMBER_INVITED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - resourceName: normalizedEmail, - description: `Invited ${normalizedEmail} as an organization ${role}`, - metadata: { - organizationId, - invitationId: pending.invitationId, - targetEmail: normalizedEmail, - organizationRole: role, - }, - request, - }) return { id: pending.invitationId, + organizationId, + role, + kind: 'organization', + status: 'pending', + createdAt: pending.mutationUpdatedAt, + expiresAt: pending.expiresAt, email: normalizedEmail, workspaceIds: [], permission: 'read', diff --git a/apps/sim/lib/invitations/send-resend.test.ts b/apps/sim/lib/invitations/send-resend.test.ts new file mode 100644 index 00000000000..9e331395711 --- /dev/null +++ b/apps/sim/lib/invitations/send-resend.test.ts @@ -0,0 +1,85 @@ +/** @vitest-environment node */ +import { invitation } from '@sim/db/schema' +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ lock: vi.fn(), authority: vi.fn() })) +vi.mock('@/lib/invitations/core', async (original) => ({ + ...(await original()), + lockInvitationForMutation: mocks.lock, + requireInvitationResendAuthority: mocks.authority, +})) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { persistInvitationResend } from '@/lib/invitations/send' + +const revision = new Date('2026-01-01') +const input = { + invitationId: 'inv', + actorUserId: 'actor', + expectedOrganizationId: 'org', + expectedUpdatedAt: revision, + nextToken: 'new-token', + nextExpiresAt: new Date('2099-02-01'), +} +beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + mocks.lock.mockResolvedValue({ + id: 'inv', + organizationId: 'org', + expiresAt: new Date('2099-01-01'), + grants: [], + }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'inv' }]) +}) +describe('resend token persistence', () => { + it('rechecks authority under the invitation locks and conditionally updates the original pending revision', async () => { + await persistInvitationResend(input) + expect(mocks.lock).toHaveBeenCalledWith(expect.anything(), 'inv', { + lockCurrentGrantWorkspaces: true, + }) + expect(mocks.authority).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'inv' }), + 'actor', + 'org' + ) + expect(mocks.authority.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.update.mock.invocationCallOrder[0] + ) + const [predicate] = dbChainMockFns.where.mock.calls[0] + for (const [column, value] of [ + [invitation.status, 'pending'], + [invitation.updatedAt, revision], + [invitation.organizationId, 'org'], + ]) + expect( + hasMockCondition( + predicate, + (node) => node.type === 'eq' && node.left === column && node.right === value + ) + ).toBe(true) + }) + + it('rejects a concurrent revision change or acceptance', async () => { + dbChainMockFns.returning.mockResolvedValue([]) + await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('rejects canonical organization changes without touching the token', async () => { + mocks.lock.mockResolvedValue({ organizationId: 'different' }) + await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects demotion and expiration during delivery', async () => { + mocks.authority.mockRejectedValueOnce( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) + await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'forbidden' }) + mocks.lock.mockResolvedValue({ organizationId: 'org', expiresAt: new Date('2000-01-01') }) + await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/invitations/send.test.ts b/apps/sim/lib/invitations/send.test.ts index bbf98994db5..8fdf3be46a0 100644 --- a/apps/sim/lib/invitations/send.test.ts +++ b/apps/sim/lib/invitations/send.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { workspace } from '@sim/db/schema' +import { invitation, workspace } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -86,6 +86,39 @@ describe('createPendingInvitation', () => { } ) + it('expires a stale pending invitation under the creation locks before validating its replacement', async () => { + const stale = { + id: 'stale', + token: 'old', + expiresAt: new Date('2000-01-01'), + organizationId: 'org-1', + role: 'member', + membershipIntent: 'internal', + updatedAt: new Date('2000-01-01'), + } + queueTableRows(invitation, [stale]) + queueTableRows(invitation, [stale]) + const validateLockedContext = vi.fn(async () => { + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: 'expired', + updatedAt: expect.any(Date), + }) + }) + const result = await createPendingInvitation({ + kind: 'organization', + email: 'invitee@example.com', + inviterId: 'actor', + organizationId: 'org-1', + role: 'member', + grants: [], + validateLockedContext, + }) + expect(result.created).toBe(true) + expect(result.invitationId).not.toBe('stale') + expect(validateLockedContext).toHaveBeenCalledOnce() + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + }) + it('rejects a grantless organization invitation without an internal organization target', async () => { for (const input of [ { organizationId: null, membershipIntent: 'internal' as const }, diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index de484524687..a075978dcd3 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -20,9 +20,14 @@ import { renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbOrTx } from '@/lib/db/types' -import { computeInvitationExpiry, lockInvitationForMutation } from '@/lib/invitations/core' +import { + computeInvitationExpiry, + lockInvitationForMutation, + requireInvitationResendAuthority, +} from '@/lib/invitations/core' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -271,8 +276,7 @@ async function createOrExtendPendingInvitation( }) const organizationId = await resolveInvitationOrganizationId(tx, input, workspaceIds) - await input.validateLockedContext?.({ tx, organizationId, workspaceIds }) - const existing = organizationId + let existing = organizationId ? await findPendingOrganizationInvitation(tx, organizationId, email) : null @@ -280,6 +284,15 @@ async function createOrExtendPendingInvitation( throw new InvitationScopeChangedError() } + if (existing && existing.expiresAt.getTime() <= now.getTime()) { + await tx + .update(invitation) + .set({ status: 'expired', updatedAt: now }) + .where(and(eq(invitation.id, existing.id), eq(invitation.status, 'pending'))) + existing = null + } + await input.validateLockedContext?.({ tx, organizationId, workspaceIds }) + if (existing) { return extendPendingInvitation(tx, { existing, input, expiresAt, now }) } @@ -696,21 +709,58 @@ export async function persistInvitationResend(params: { invitationId: string nextToken: string | null nextExpiresAt: Date + expectedOrganizationId?: string + expectedUpdatedAt: Date + actorUserId: string }): Promise { - const [row] = await db - .update(invitation) - .set({ - expiresAt: params.nextExpiresAt, - updatedAt: new Date(), - ...(params.nextToken ? { token: params.nextToken } : {}), - }) - .where(and(eq(invitation.id, params.invitationId), eq(invitation.status, 'pending'))) - .returning({ id: invitation.id }) - - if (!row) { - throw new Error(`Invitation ${params.invitationId} not found or no longer pending`) + async function persist(executor: DbOrTx) { + const [row] = await executor + .update(invitation) + .set({ + expiresAt: params.nextExpiresAt, + updatedAt: new Date(), + ...(params.nextToken ? { token: params.nextToken } : {}), + }) + .where( + and( + eq(invitation.id, params.invitationId), + eq(invitation.status, 'pending'), + params.expectedOrganizationId === undefined + ? undefined + : eq(invitation.organizationId, params.expectedOrganizationId), + eq(invitation.updatedAt, params.expectedUpdatedAt) + ) + ) + .returning({ id: invitation.id }) + if (!row) + throw new OrchestrationError( + 'conflict', + 'The invitation changed while the email was being delivered. Refresh before resending.' + ) } - + await db.transaction(async (tx) => { + const current = await lockInvitationForMutation(tx, params.invitationId, { + lockCurrentGrantWorkspaces: true, + }) + if ( + !current || + (params.expectedOrganizationId !== undefined && + current.organizationId !== params.expectedOrganizationId) + ) + throw new OrchestrationError('not_found', 'Invitation not found') + await requireInvitationResendAuthority( + tx, + current, + params.actorUserId, + params.expectedOrganizationId + ) + if (current.expiresAt.getTime() <= Date.now()) + throw new OrchestrationError( + 'conflict', + 'The invitation expired while the email was being delivered. Create a new invitation.' + ) + await persist(tx) + }) logger.info('Persisted invitation resend', { invitationId: params.invitationId, rotated: !!params.nextToken, diff --git a/apps/sim/lib/organizations/application/invitations.ts b/apps/sim/lib/organizations/application/invitations.ts new file mode 100644 index 00000000000..3fea0bb4b07 --- /dev/null +++ b/apps/sim/lib/organizations/application/invitations.ts @@ -0,0 +1,58 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createOrganizationInvitation as createOrganizationInvitationRecord, + prepareOrganizationInvitationContext, +} from '@/lib/invitations/organization-invitations' +import { organizationOperations } from '@/lib/organizations/application/operations' + +export interface CreateOrganizationInvitationInput { + organizationId: string + email: string + role: 'member' | 'admin' +} + +async function creationContext({ + input, + context, +}: OrganizationUseCaseContext) { + const [inviter] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, context.userId)) + .limit(1) + if (!inviter) throw new OrchestrationError('not_found', 'Authenticated user not found') + return prepareOrganizationInvitationContext({ + organizationId: input.organizationId, + inviterId: context.userId, + inviterName: inviter.name || inviter.email || 'A user', + inviterEmail: inviter.email, + }) +} + +export const createOrganizationInvitation = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.createInvitation, + async execute(args: OrganizationUseCaseContext) { + const context = await creationContext(args) + return createOrganizationInvitationRecord({ + context, + email: args.input.email, + role: args.input.role, + }) + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: input.organizationId, + resourceName: result.email, + description: `Invited ${result.email} as an organization ${input.role}`, + metadata: { invitationId: result.id, targetEmail: result.email, organizationRole: input.role }, + }), +}) diff --git a/apps/sim/lib/organizations/application/members.ts b/apps/sim/lib/organizations/application/members.ts new file mode 100644 index 00000000000..6547956581a --- /dev/null +++ b/apps/sim/lib/organizations/application/members.ts @@ -0,0 +1,86 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { + removeOrganizationMemberRecord, + updateOrganizationMemberRecord, +} from '@/lib/organizations/member-manager' + +export const updateOrganizationMember = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.updateMember, + execute: ({ + input, + context, + }: { + input: { organizationId: string; userId: string; role: 'member' | 'admin' | 'owner' } + context: { userId: string } + }) => updateOrganizationMemberRecord({ ...input, actorUserId: context.userId }), + projectAudit: ({ input, result }) => + result.changed + ? [ + { + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: input.organizationId, + description: `Changed role for member ${input.userId} to ${input.role}`, + metadata: { + targetUserId: input.userId, + targetEmail: result.member.userEmail, + targetName: result.member.userName, + changes: [{ field: 'role', from: result.previousRole, to: input.role }], + }, + }, + ] + : [], +}) + +export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.removeMember, + authorizeResource: ({ input, context }) => { + if (!isOrgAdminRole(context.role) && input.userId !== context.userId) + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Forbidden - Insufficient permissions' + ) + }, + async execute({ + input, + context, + principal, + }: OrganizationUseCaseContext<{ organizationId: string; userId: string }>) { + const result = await removeOrganizationMemberRecord({ + ...input, + actorUserId: context.userId, + ...(principal.kind === 'session' && input.userId === principal.userId + ? { spareSessionId: principal.sessionId } + : {}), + }) + return { + ...result, + removedBy: context.userId, + removedAt: new Date().toISOString(), + wasSelfRemoval: input.userId === context.userId, + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: input.organizationId, + description: result.wasSelfRemoval + ? 'Left the organization' + : `Removed member ${input.userId} from organization`, + metadata: { + targetUserId: input.userId, + targetEmail: result.target.userEmail, + targetName: result.target.userName, + ...(result.membershipType === 'external' + ? { membershipType: 'external', ...result.removal } + : { wasSelfRemoval: result.wasSelfRemoval, seatReduction: result.seatReduction }), + }, + }), +}) diff --git a/apps/sim/lib/organizations/application/operations.ts b/apps/sim/lib/organizations/application/operations.ts new file mode 100644 index 00000000000..b4dc3dfd84d --- /dev/null +++ b/apps/sim/lib/organizations/application/operations.ts @@ -0,0 +1,91 @@ +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' + +export const organizationOperations = { + /** + * permission-group-exempt: membership discovery has no resource capability; credential policy is rechecked for each organization. + */ + list: defineOrganizationOperation({ + id: 'organizations.list', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: organization metadata is available to its members independently of its member directory. + */ + read: defineOrganizationOperation({ + id: 'organizations.read', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: administrators must be able to discover workspaces they govern. + */ + listWorkspaces: defineOrganizationOperation({ + id: 'organizations.workspaces.list', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: the application enforces organization.member_directory for ordinary members; administrators retain access to member and seat administration. + */ + listMembers: defineOrganizationOperation({ + id: 'organizations.members.list', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: organization administrators manage member roles independently of member-directory visibility. + */ + updateMember: defineOrganizationOperation({ + id: 'organizations.members.update', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: the application allows administrators to remove members and members to leave their own organization. + */ + removeMember: defineOrganizationOperation({ + id: 'organizations.members.remove', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: administrators can inspect existing invitations even when new invitations are disabled. + */ + listInvitations: defineOrganizationOperation({ + id: 'organizations.invitations.list', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: inspecting an invitation does not admit a new member. + */ + readInvitation: defineOrganizationOperation({ + id: 'organizations.invitations.read', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + createInvitation: defineOrganizationOperation({ + id: 'organizations.invitations.create', + minimumRole: 'admin', + capability: 'invitations.send', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), +} as const diff --git a/apps/sim/lib/organizations/application/reads.ts b/apps/sim/lib/organizations/application/reads.ts new file mode 100644 index 00000000000..b4ad006ef7d --- /dev/null +++ b/apps/sim/lib/organizations/application/reads.ts @@ -0,0 +1,164 @@ +import type { InvitationStatus } from '@sim/db/schema' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { + getOrganizationSeatAnalytics, + getOrganizationSeatInfo, +} from '@/lib/billing/validation/seat-management' +import { defineAuthorizedOrganizationUseCase } from '@/lib/core/application/authorized-organization-use-case' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { + type OrganizationMemberPageInput, + readOrganizationMemberPage, +} from '@/lib/organizations/member-queries' +import { + listOrganizationInvitationRecords, + listOrganizationRecordsForUser, + listOrganizationWorkspaceRecords, + type OrganizationInvitationSortBy, + type OrganizationListOptions, + type OrganizationSortBy, + type OrganizationWorkspaceSortBy, + organizationCursorKeys, + requireOrganizationInvitationRecord, + requireOrganizationRecord, +} from '@/lib/organizations/queries' +import { refuseCapability } from '@/lib/permission-groups/capabilities' +import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' + +export interface OrganizationInput { + organizationId: string +} +export interface OrganizationInvitationInput extends OrganizationInput { + invitationId: string +} + +export const listOrganizations: OperationUseCase< + typeof organizationOperations.list, + OrganizationListOptions, + Awaited> +> = { + operation: organizationOperations.list, + async execute({ principal, input }) { + if ( + principal.kind !== 'session' && + principal.kind !== 'personal_api_key' && + principal.kind !== 'oauth_access_token' + ) + throw new PrincipalKindAuthorizationError(principal.kind, organizationOperations.list.id) + requireOAuthOperationScope(principal, organizationOperations.list) + const data: Awaited>['data'] = [] + let cursorKeys = input.cursorKeys + do { + const page = await listOrganizationRecordsForUser(principal.userId, { ...input, cursorKeys }) + for (const row of page.data) { + try { + const context = await authorizeOrganizationOperation( + principal, + organizationOperations.list, + { organizationId: row.id } + ) + data.push({ ...row, role: context.role }) + if (data.length > input.limit) { + return { + data: data.slice(0, input.limit), + nextCursorKeys: organizationCursorKeys(data[input.limit - 1]!, input.sortBy), + } + } + } catch (error) { + if ( + !(error instanceof OrchestrationError) || + (error.code !== 'forbidden' && error.code !== 'not_found') + ) + throw error + } + } + cursorKeys = page.nextCursorKeys ?? undefined + } while (cursorKeys) + return { data, nextCursorKeys: null } + }, +} + +export const getOrganization = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.read, + async execute({ + input, + context, + }: { + input: OrganizationInput & { includeSeats?: boolean } + context: { role: string } + }) { + const organization = await requireOrganizationRecord(input.organizationId) + const hasAdminAccess = isOrgAdminRole(context.role) + const seats = input.includeSeats ? await getOrganizationSeatInfo(input.organizationId) : null + const seatAnalytics = + input.includeSeats && hasAdminAccess + ? await getOrganizationSeatAnalytics(input.organizationId) + : null + return { + ...organization, + role: context.role, + hasAdminAccess, + ...(seats ? { seats } : {}), + ...(seatAnalytics ? { seatAnalytics } : {}), + } + }, +}) + +/** permission-group-enforced: organization.member_directory — administrators retain their member-management surface. */ +export async function requireOrganizationMemberDirectory(organizationId: string, role: string) { + if ( + !isOrgAdminRole(role) && + (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) + refuseCapability('organization.member_directory') +} + +export const listOrganizationMembers = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.listMembers, + authorizeResource: ({ input, context }) => + requireOrganizationMemberDirectory(input.organizationId, context.role), + async execute({ + input, + context, + }: { + input: OrganizationInput & OrganizationMemberPageInput + context: { role: string } + }) { + const hasAdminAccess = isOrgAdminRole(context.role) + const page = await readOrganizationMemberPage(input.organizationId, { + ...input, + includeUsage: Boolean(input.includeUsage && hasAdminAccess), + }) + return { ...page, userRole: context.role, hasAdminAccess } + }, +}) + +export const listOrganizationWorkspaces = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.listWorkspaces, + execute: ({ + input, + }: { + input: OrganizationInput & OrganizationListOptions + }) => listOrganizationWorkspaceRecords(input.organizationId, input), +}) + +export const listOrganizationInvitations = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.listInvitations, + execute: ({ + input, + }: { + input: OrganizationInput & + OrganizationListOptions & { status?: InvitationStatus } + }) => listOrganizationInvitationRecords(input.organizationId, input), +}) + +export const getOrganizationInvitation = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.readInvitation, + execute: ({ input }: { input: OrganizationInvitationInput }) => + requireOrganizationInvitationRecord(input.organizationId, input.invitationId), +}) diff --git a/apps/sim/lib/organizations/application/use-cases.test.ts b/apps/sim/lib/organizations/application/use-cases.test.ts new file mode 100644 index 00000000000..739d83867f7 --- /dev/null +++ b/apps/sim/lib/organizations/application/use-cases.test.ts @@ -0,0 +1,328 @@ +/** @vitest-environment node */ +import { recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { member, user } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', async (importOriginal) => ({ + ...(await importOriginal()), + recordAudit: vi.fn(), +})) +const mocks = vi.hoisted(() => ({ + config: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + create: vi.fn(), + prepare: vi.fn(), + resend: vi.fn(), + revoke: vi.fn(), + members: vi.fn(), + organizations: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.config, +})) +vi.mock('@/lib/organizations/member-manager', () => ({ + updateOrganizationMemberRecord: mocks.update, + removeOrganizationMemberRecord: mocks.remove, +})) +vi.mock('@/lib/invitations/organization-invitations', () => ({ + prepareOrganizationInvitationContext: mocks.prepare, + createOrganizationInvitation: mocks.create, +})) +vi.mock('@/lib/organizations/member-queries', () => ({ readOrganizationMemberPage: mocks.members })) +vi.mock('@/lib/organizations/queries', async (importOriginal) => ({ + ...(await importOriginal()), + listOrganizationRecordsForUser: mocks.organizations, +})) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { createOrganizationInvitation } from '@/lib/organizations/application/invitations' +import { + removeOrganizationMember, + updateOrganizationMember, +} from '@/lib/organizations/application/members' +import { organizationOperations } from '@/lib/organizations/application/operations' +import { listOrganizationMembers, listOrganizations } from '@/lib/organizations/application/reads' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const session: Principal = { kind: 'session', userId: 'actor', sessionId: 'current-session' } +const key: Principal = { kind: 'personal_api_key', userId: 'actor', keyId: 'key' } +const oauth: Principal = { + kind: 'oauth_access_token', + userId: 'actor', + clientId: 'client', + tokenId: 'token', + scopes: ['api:read', 'api:write'], + expiresAt: new Date('2099-01-01'), +} +const roleInput = { organizationId: 'org', userId: 'target', role: 'admin' as const } +const inviteInput = { organizationId: 'org', email: 'person@example.com', role: 'member' as const } +const target = { + id: 'membership', + userId: 'target', + organizationId: 'org', + role: 'member', + userName: 'Person', + userEmail: 'person@example.com', + createdAt: new Date('2026-01-01'), +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.config.mockResolvedValue(null) + mocks.update.mockResolvedValue({ + member: { ...target, role: 'admin' }, + previousRole: 'member', + changed: true, + }) + mocks.remove.mockResolvedValue({ + target, + membershipType: 'internal', + removal: { success: true }, + seatReduction: null, + }) + mocks.prepare.mockImplementation(async (value) => value) + mocks.create.mockResolvedValue({ id: 'invitation', email: inviteInput.email }) + mocks.members.mockResolvedValue({ data: [], nextCursorKeys: null }) +}) + +describe('organization application operations', () => { + it.each([session, key, oauth])( + 'uses the real $kind actor for role changes and semantic audit', + async (principal) => { + queueTableRows(member, [{ role: 'admin' }]) + await updateOrganizationMember.execute({ principal, input: roleInput }) + expect(mocks.update).toHaveBeenCalledWith({ ...roleInput, actorUserId: 'actor' }) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'actor', + resourceId: 'org', + metadata: expect.objectContaining({ + operation: 'organizations.members.update', + actor: expect.objectContaining({ kind: principal.kind }), + }), + }) + ) + } + ) + + it.each(Object.values(organizationOperations))( + 'rejects workspace principals for $id', + async (operation) => { + expect(operation.principalKinds).not.toContain('workspace_api_key') + } + ) + + it('rejects workspace keys before protected loading', async () => { + await expect( + updateOrganizationMember.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'workspace-key' }, + input: roleInput, + }) + ).rejects.toMatchObject({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('conceals other organizations and requires an administrator for role changes', async () => { + await expect( + updateOrganizationMember.execute({ principal: key, input: roleInput }) + ).rejects.toMatchObject({ code: 'not_found' }) + queueTableRows(member, [{ role: 'member' }]) + await expect( + updateOrganizationMember.execute({ principal: key, input: roleInput }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it.each([ + [key, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)('rechecks current credential policy', async (principal, restriction) => { + queueTableRows(member, [{ role: 'owner' }]) + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction }) + await expect( + updateOrganizationMember.execute({ principal, input: roleInput }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('requires write scope before membership or mutation loading', async () => { + await expect( + updateOrganizationMember.execute({ + principal: { ...oauth, scopes: ['api:read'] }, + input: roleInput, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('does not audit an unchanged role or failed write', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.update.mockResolvedValueOnce({ member: target, changed: false }) + await updateOrganizationMember.execute({ principal: session, input: roleInput }) + queueTableRows(member, [{ role: 'admin' }]) + mocks.update.mockRejectedValueOnce(new Error('database unavailable')) + await expect( + updateOrganizationMember.execute({ principal: session, input: roleInput }) + ).rejects.toThrow('database unavailable') + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('allows self-removal and preserves only the acting session by verified row ID', async () => { + queueTableRows(member, [{ role: 'member' }]) + await removeOrganizationMember.execute({ + principal: session, + input: { organizationId: 'org', userId: 'actor' }, + }) + expect(mocks.remove).toHaveBeenCalledWith({ + organizationId: 'org', + userId: 'actor', + actorUserId: 'actor', + spareSessionId: 'current-session', + }) + }) + + it('does not let members remove somebody else or preserve a session on credential-based removal', async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect( + removeOrganizationMember.execute({ principal: session, input: roleInput }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + expect(mocks.remove).not.toHaveBeenCalled() + queueTableRows(member, [{ role: 'admin' }]) + await removeOrganizationMember.execute({ principal: key, input: roleInput }) + expect(mocks.remove).toHaveBeenCalledWith( + expect.not.objectContaining({ spareSessionId: expect.anything() }) + ) + }) + + it.each(['admin', 'owner'])( + 'retains member-directory access for %s when directory visibility is disabled', + async (role) => { + queueTableRows(member, [{ role }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideOrgMemberDirectory: true, + }) + await listOrganizationMembers.execute({ + principal: key, + input: { organizationId: 'org', sortBy: 'name', sortOrder: 'asc', limit: 10 }, + }) + expect(mocks.members).toHaveBeenCalledOnce() + } + ) + + it('denies directory access to a restricted member and suppresses non-admin usage enrichment', async () => { + queueTableRows(member, [{ role: 'member' }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideOrgMemberDirectory: true, + }) + await expect( + listOrganizationMembers.execute({ + principal: session, + input: { organizationId: 'org', sortBy: 'name', sortOrder: 'asc', limit: 10 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + mocks.config.mockResolvedValue(null) + queueTableRows(member, [{ role: 'member' }]) + await listOrganizationMembers.execute({ + principal: session, + input: { + organizationId: 'org', + sortBy: 'name', + sortOrder: 'asc', + limit: 10, + includeUsage: true, + }, + }) + expect(mocks.members).toHaveBeenCalledWith( + 'org', + expect.objectContaining({ includeUsage: false }) + ) + }) + + it.each([session, key, oauth])( + 'creates invitations with $kind audit attribution after delivery', + async (principal) => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(user, [{ name: 'Acting Admin', email: 'admin@example.com' }]) + await createOrganizationInvitation.execute({ principal, input: inviteInput }) + expect(mocks.create).toHaveBeenCalledWith({ + context: expect.objectContaining({ inviterId: 'actor', organizationId: 'org' }), + email: inviteInput.email, + role: 'member', + }) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + actor: expect.objectContaining({ kind: principal.kind }), + invitationId: 'invitation', + }), + }) + ) + } + ) + + it('withheld invitations prevent creation', async () => { + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableInvitations: true }) + queueTableRows(member, [{ role: 'admin' }]) + await expect( + createOrganizationInvitation.execute({ principal: key, input: inviteInput }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('filters inaccessible organizations without concealing infrastructure failures', async () => { + mocks.organizations.mockResolvedValue({ + data: [{ id: 'org', name: 'Organization' }], + nextCursorKeys: null, + }) + queueTableRows(member, []) + await expect( + listOrganizations.execute({ + principal: key, + input: { sortBy: 'name', sortOrder: 'asc', limit: 1 }, + }) + ).resolves.toMatchObject({ data: [], nextCursorKeys: null }) + dbChainMockFns.select.mockImplementationOnce(() => { + throw new Error('database unavailable') + }) + await expect( + listOrganizations.execute({ + principal: key, + input: { sortBy: 'name', sortOrder: 'asc', limit: 1 }, + }) + ).rejects.toThrow('database unavailable') + }) + it('fills pages after filtering and anchors cursors only to authorized organizations', async () => { + mocks.organizations + .mockResolvedValueOnce({ + data: [{ id: 'hidden', name: 'Hidden' }], + nextCursorKeys: ['Hidden', 'hidden'], + }) + .mockResolvedValueOnce({ + data: [{ id: 'one', name: 'One', role: 'admin' }], + nextCursorKeys: ['One', 'one'], + }) + .mockResolvedValueOnce({ + data: [{ id: 'two', name: 'Two', role: 'admin' }], + nextCursorKeys: null, + }) + queueTableRows(member, []) + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, [{ role: 'admin' }]) + const result = await listOrganizations.execute({ + principal: key, + input: { sortBy: 'name', sortOrder: 'asc', limit: 1 }, + }) + expect(result).toEqual({ + data: [{ id: 'one', name: 'One', role: 'member' }], + nextCursorKeys: ['One', 'one'], + }) + }) +}) diff --git a/apps/sim/lib/organizations/member-manager.test.ts b/apps/sim/lib/organizations/member-manager.test.ts new file mode 100644 index 00000000000..6baf9e1c5ef --- /dev/null +++ b/apps/sim/lib/organizations/member-manager.test.ts @@ -0,0 +1,103 @@ +/** @vitest-environment node */ +import { member } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + locks: vi.fn(), + scim: vi.fn(), + remove: vi.fn(), + external: vi.fn(), + seats: vi.fn(), +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationUserMutationLocks: mocks.locks, + removeUserFromOrganization: mocks.remove, + removeExternalUserFromOrganizationWorkspaces: mocks.external, + WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR: 'Billing owner cannot be removed', +})) +vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: mocks.seats })) +vi.mock('@/ee/scim/lib/managed-membership', () => ({ assertMembershipNotScimManaged: mocks.scim })) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { + removeOrganizationMemberRecord, + updateOrganizationMemberRecord, +} from '@/lib/organizations/member-manager' + +const input = { + organizationId: 'org', + actorUserId: 'actor', + userId: 'target', + role: 'admin' as const, +} +const target = { + id: 'membership', + userId: 'target', + organizationId: 'org', + role: 'member', + userName: 'Member', + userEmail: 'person@example.com', + createdAt: new Date(), +} +beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + mocks.remove.mockResolvedValue({ success: true }) + mocks.seats.mockResolvedValue({ changed: false }) +}) +describe('organization member managers', () => { + it('rejects a demoted actor under the mutation lock', async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect(updateOrganizationMemberRecord(input)).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'ORGANIZATION_ADMIN_REQUIRED', + }) + expect(mocks.locks.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('keeps owner protection and SCIM authority in the locked mutation', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ ...target, role: 'owner' }]) + await expect(updateOrganizationMemberRecord(input)).rejects.toMatchObject({ + code: 'validation', + }) + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [target]) + mocks.scim.mockRejectedValue( + new ForbiddenOperationError('SCIM_MANAGED_MEMBERSHIP', 'Managed by SCIM') + ) + await expect(updateOrganizationMemberRecord(input)).rejects.toMatchObject({ + detailCode: 'SCIM_MANAGED_MEMBERSHIP', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns unchanged when the role is already current', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ ...target, role: 'admin' }]) + queueTableRows(member, [{ id: 'membership', role: 'admin' }]) + await expect(updateOrganizationMemberRecord(input)).resolves.toMatchObject({ + changed: false, + previousRole: 'admin', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('preserves successful emergency removal after billing reconciliation fails', async () => { + queueTableRows(member, [target]) + mocks.seats.mockRejectedValue(new Error('Billing unavailable')) + await expect(removeOrganizationMemberRecord(input)).resolves.toMatchObject({ + membershipType: 'internal', + removal: { success: true }, + seatReduction: { changed: false }, + }) + expect(mocks.remove).toHaveBeenCalledWith( + expect.objectContaining({ actorUserId: 'actor', memberId: 'membership' }) + ) + expect(mocks.scim).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/organizations/member-manager.ts b/apps/sim/lib/organizations/member-manager.ts new file mode 100644 index 00000000000..54991c25a17 --- /dev/null +++ b/apps/sim/lib/organizations/member-manager.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { + acquireOrganizationUserMutationLocks, + removeExternalUserFromOrganizationWorkspaces, + removeUserFromOrganization, + WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR, +} from '@/lib/billing/organizations/membership' +import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireMemberManagementAuthority } from '@/lib/organizations/members/authority' +import { changeMemberRoleTx } from '@/lib/organizations/members/lifecycle' +import { findOrganizationMemberRecord } from '@/lib/organizations/queries' +import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' + +const logger = createLogger('OrganizationMemberManager') + +export async function updateOrganizationMemberRecord(input: { + organizationId: string + userId: string + role: 'member' | 'admin' | 'owner' + actorUserId: string +}) { + return db.transaction(async (tx) => { + await acquireOrganizationUserMutationLocks(tx, { + userId: input.userId, + organizationIds: [input.organizationId], + }) + await requireMemberManagementAuthority(tx, input.organizationId, input.actorUserId) + const target = await findOrganizationMemberRecord(input.organizationId, input.userId, tx) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + if (target.role === 'owner') + throw new OrchestrationError('validation', 'Cannot change owner role') + if (input.role === 'owner') + throw new OrchestrationError( + 'validation', + 'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.' + ) + await assertMembershipNotScimManaged({ + organizationId: input.organizationId, + userId: input.userId, + executor: tx, + }) + const change = await changeMemberRoleTx(tx, { ...input, role: input.role }) + return { + member: { ...target, role: input.role }, + previousRole: target.role, + changed: change.changed, + } + }) +} + +export async function removeOrganizationMemberRecord(input: { + organizationId: string + userId: string + actorUserId: string + spareSessionId?: string +}) { + const target = await findOrganizationMemberRecord(input.organizationId, input.userId) + if (!target) { + const [external] = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, input.userId)) + .limit(1) + if (!external) throw new OrchestrationError('not_found', 'Member not found') + const removal = await removeExternalUserFromOrganizationWorkspaces(input) + if (!removal.success) { + const message = removal.error || 'External workspace member not found' + const code = + message === 'External workspace member not found' + ? 'not_found' + : message === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR + ? 'validation' + : 'internal' + throw new OrchestrationError(code, message) + } + return { + membershipType: 'external' as const, + target: { userId: external.id, userName: external.name, userEmail: external.email }, + removal, + seatReduction: null, + } + } + const removal = await removeUserFromOrganization({ ...input, memberId: target.id }) + if (!removal.success) { + const message = removal.error || 'Failed to remove user from organization' + const code = + message === 'Member not found' + ? 'not_found' + : message === 'Cannot remove organization owner' || + message === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR + ? 'validation' + : 'internal' + throw new OrchestrationError(code, message) + } + let seatReduction: Awaited> + try { + seatReduction = await reconcileOrganizationSeats({ + organizationId: input.organizationId, + reason: 'member-removed', + actorId: input.actorUserId, + }) + } catch (error) { + logger.error('Failed to reduce seats after member removal', { + organizationId: input.organizationId, + error, + }) + seatReduction = { changed: false, reason: 'Failed to reduce seats after member removal' } + } + return { membershipType: 'internal' as const, target, removal, seatReduction } +} diff --git a/apps/sim/lib/organizations/member-queries.ts b/apps/sim/lib/organizations/member-queries.ts new file mode 100644 index 00000000000..06e3ef8c046 --- /dev/null +++ b/apps/sim/lib/organizations/member-queries.ts @@ -0,0 +1,83 @@ +import { db } from '@sim/db' +import { member, user, userStats } from '@sim/db/schema' +import { count, eq, inArray } from 'drizzle-orm' +import type { CursorKey } from '@/lib/api/list-query' +import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' +import { + listOrganizationMemberRecords, + type OrganizationListOptions, + type OrganizationMemberSortBy, + organizationMemberSelection, +} from '@/lib/organizations/queries' + +export type OrganizationMemberPageInput = OrganizationListOptions & { + offset?: number + includeUsage?: boolean +} +type MemberRecord = Awaited>['data'][number] +export interface OrganizationMemberUsageRecord extends MemberRecord { + currentUsageLimit?: string | null + usageLimitUpdatedAt?: Date | null + currentPeriodCost?: string + billingPeriodStart?: Date | null + billingPeriodEnd?: Date | null +} + +/** Preserves offset pagination for the internal directory while public clients use keysets. */ +export async function readOrganizationMemberPage( + organizationId: string, + input: OrganizationMemberPageInput +): Promise<{ + data: OrganizationMemberUsageRecord[] + nextCursorKeys: CursorKey[] | null + total?: number +}> { + let data: OrganizationMemberUsageRecord[] + let nextCursorKeys: CursorKey[] | null = null + let total: number | undefined + if (input.offset !== undefined) { + const [rows, totals] = await Promise.all([ + db + .select(organizationMemberSelection) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + .orderBy(user.name, user.id) + .limit(input.limit) + .offset(input.offset), + db.select({ value: count() }).from(member).where(eq(member.organizationId, organizationId)), + ]) + data = rows + total = totals[0]?.value ?? 0 + } else { + const page = await listOrganizationMemberRecords(organizationId, input) + data = page.data + nextCursorKeys = page.nextCursorKeys + } + if (input.includeUsage) { + const ids = data.map((row) => row.userId) + const [limits, snapshot] = await Promise.all([ + ids.length + ? db + .select({ + userId: userStats.userId, + currentUsageLimit: userStats.currentUsageLimit, + usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, + }) + .from(userStats) + .where(inArray(userStats.userId, ids)) + : [], + getOrganizationMemberUsageSnapshot(organizationId, { userIds: ids }), + ]) + const byUser = new Map(limits.map((row) => [row.userId, row])) + data = data.map((row) => ({ + ...row, + currentUsageLimit: byUser.get(row.userId)?.currentUsageLimit ?? null, + usageLimitUpdatedAt: byUser.get(row.userId)?.usageLimitUpdatedAt ?? null, + currentPeriodCost: (snapshot.usageByUser.get(row.userId) ?? 0).toString(), + billingPeriodStart: snapshot.billingPeriod?.start ?? null, + billingPeriodEnd: snapshot.billingPeriod?.end ?? null, + })) + } + return { data, nextCursorKeys, total } +} diff --git a/apps/sim/lib/organizations/members/authority.ts b/apps/sim/lib/organizations/members/authority.ts new file mode 100644 index 00000000000..ae865635d8c --- /dev/null +++ b/apps/sim/lib/organizations/members/authority.ts @@ -0,0 +1,26 @@ +import { member } from '@sim/db/schema' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq } from 'drizzle-orm' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import type { DbOrTx } from '@/lib/db/types' + +/** Rechecks the acting member while the organization mutation lock is held. */ +export async function requireMemberManagementAuthority( + executor: DbOrTx, + organizationId: string, + actorUserId: string, + selfRemovalUserId?: string +): Promise { + const [actor] = await executor + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, actorUserId))) + .for('update') + .limit(1) + if (!actor || (!isOrgAdminRole(actor.role) && actorUserId !== selfRemovalUserId)) { + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization administrator access is required' + ) + } +} diff --git a/apps/sim/lib/organizations/members/lifecycle.test.ts b/apps/sim/lib/organizations/members/lifecycle.test.ts index a8df650baa0..3e7274cb1cb 100644 --- a/apps/sim/lib/organizations/members/lifecycle.test.ts +++ b/apps/sim/lib/organizations/members/lifecycle.test.ts @@ -120,6 +120,21 @@ describe('revokeUserSessionsTx', () => { ) }) + it('preserves the verified session ID independently of its token', async () => { + await revokeUserSessionsTx(db, { + userId: 'u-1', + organizationId: 'org-1', + spareSessionId: 'verified-id', + }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { type: 'ne', left: session.id, right: 'verified-id' }, + ]), + }) + ) + }) + it('clears the caches only through the separate post-commit step', () => { invalidateAfterSessionRevocation({ userId: 'u-1', organizationId: 'org-1' }) expect(mockInvalidateVersion).toHaveBeenCalledWith('org-1') diff --git a/apps/sim/lib/organizations/members/revocation.ts b/apps/sim/lib/organizations/members/revocation.ts index b1ba25881c3..89b8f14b048 100644 --- a/apps/sim/lib/organizations/members/revocation.ts +++ b/apps/sim/lib/organizations/members/revocation.ts @@ -32,7 +32,12 @@ export interface RevokeSessionsResult { */ export async function revokeUserSessionsTx( tx: DbOrTx, - params: { userId: string; organizationId: string; spareSessionToken?: string } + params: { + userId: string + organizationId: string + spareSessionToken?: string + spareSessionId?: string + } ): Promise { const deleted = await tx .delete(sessionTable) @@ -40,7 +45,8 @@ export async function revokeUserSessionsTx( and( eq(sessionTable.userId, params.userId), isNull(sessionTable.impersonatedBy), - ...(params.spareSessionToken ? [ne(sessionTable.token, params.spareSessionToken)] : []) + ...(params.spareSessionToken ? [ne(sessionTable.token, params.spareSessionToken)] : []), + ...(params.spareSessionId ? [ne(sessionTable.id, params.spareSessionId)] : []) ) ) .returning({ id: sessionTable.id }) diff --git a/apps/sim/lib/organizations/queries.ts b/apps/sim/lib/organizations/queries.ts new file mode 100644 index 00000000000..c1425129b94 --- /dev/null +++ b/apps/sim/lib/organizations/queries.ts @@ -0,0 +1,231 @@ +import { db } from '@sim/db' +import { + type InvitationStatus, + invitation, + member, + organization, + user, + workspace, +} from '@sim/db/schema' +import { and, eq, isNull, or, sql } from 'drizzle-orm' +import { + type CursorKey, + encodeKeyset, + type KeysetKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' + +export interface OrganizationListOptions { + sortBy: SortBy + sortOrder: ListSortOrder + limit: number + cursorKeys?: CursorKey[] + search?: string +} +export type OrganizationSortBy = 'name' | 'createdAt' +export type OrganizationMemberSortBy = 'name' | 'email' | 'joinedAt' +export type OrganizationWorkspaceSortBy = 'name' | 'id' +export type OrganizationInvitationSortBy = 'email' | 'createdAt' + +const organizationSelection = { + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.logo, + createdAt: organization.createdAt, +} +type OrganizationRow = { id: string; name: string; createdAt: Date } +const organizationSortKeys = { + name: textKey(organization.name, (row) => row.name), + createdAt: timestampKey(organization.createdAt, (row) => row.createdAt), +} satisfies Record> + +function organizationKeys(sortBy: OrganizationSortBy) { + return [organizationSortKeys[sortBy], textKey(organization.id, (row) => row.id)] +} + +export function organizationCursorKeys(row: OrganizationRow, sortBy: OrganizationSortBy) { + return encodeKeyset(organizationKeys(sortBy), row) +} + +export async function listOrganizationRecordsForUser( + userId: string, + options: OrganizationListOptions +) { + const keys = organizationKeys(options.sortBy) + const rows = await db + .select({ ...organizationSelection, role: member.role }) + .from(organization) + .innerJoin(member, eq(member.organizationId, organization.id)) + .where( + and( + eq(member.userId, userId), + searchFilter(organization.name, options.search), + resumeKeyset(keys, options.cursorKeys, options.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), options.sortOrder)) + .limit(options.limit + 1) + return keysetPage(keys, rows, options.limit) +} + +export async function requireOrganizationRecord(organizationId: string) { + const [row] = await db + .select({ + ...organizationSelection, + metadata: organization.metadata, + updatedAt: organization.updatedAt, + }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + if (!row) throw new OrchestrationError('not_found', 'Organization not found') + return row +} + +export const organizationMemberSelection = { + id: member.id, + userId: member.userId, + organizationId: member.organizationId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, +} +type MemberRow = { userId: string; userName: string; userEmail: string; createdAt: Date } +const memberSortKeys = { + name: textKey(user.name, (row) => row.userName), + email: textKey(user.email, (row) => row.userEmail), + joinedAt: timestampKey(member.createdAt, (row) => row.createdAt), +} satisfies Record> + +export async function listOrganizationMemberRecords( + organizationId: string, + options: OrganizationListOptions +) { + const keys = [ + memberSortKeys[options.sortBy], + textKey(member.userId, (row) => row.userId), + ] + const rows = await db + .select(organizationMemberSelection) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where( + and( + eq(member.organizationId, organizationId), + options.search === undefined + ? undefined + : or(searchFilter(user.name, options.search), searchFilter(user.email, options.search)), + resumeKeyset(keys, options.cursorKeys, options.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), options.sortOrder)) + .limit(options.limit + 1) + return keysetPage(keys, rows, options.limit) +} + +export async function findOrganizationMemberRecord( + organizationId: string, + userId: string, + executor: DbOrTx = db +) { + const [row] = await executor + .select(organizationMemberSelection) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1) + return row ?? null +} + +export async function listOrganizationWorkspaceRecords( + organizationId: string, + options: OrganizationListOptions +) { + type WorkspaceRow = { id: string; name: string } + const idKey = textKey(workspace.id, (row) => row.id) + const keys = + options.sortBy === 'id' + ? [idKey] + : [textKey(workspace.name, (row) => row.name), idKey] + const rows = await db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where( + and( + eq(workspace.organizationId, organizationId), + isNull(workspace.archivedAt), + searchFilter(workspace.name, options.search), + resumeKeyset(keys, options.cursorKeys, options.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), options.sortOrder)) + .limit(options.limit + 1) + return keysetPage(keys, rows, options.limit) +} + +/** Expiration is projected without mutating invitations during a read. */ +const invitationStatus = sql`case when ${invitation.status} = 'pending' and ${invitation.expiresAt} <= now() then 'expired' else ${invitation.status}::text end` +const invitationSelection = { + id: invitation.id, + organizationId: invitation.organizationId, + email: invitation.email, + role: invitation.role, + kind: invitation.kind, + membershipIntent: invitation.membershipIntent, + status: invitationStatus, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, +} +type InvitationRow = { id: string; email: string; createdAt: Date } +const invitationSortKeys = { + email: textKey(invitation.email, (row) => row.email), + createdAt: timestampKey(invitation.createdAt, (row) => row.createdAt), +} satisfies Record> + +export async function listOrganizationInvitationRecords( + organizationId: string, + options: OrganizationListOptions & { status?: InvitationStatus } +) { + const keys = [ + invitationSortKeys[options.sortBy], + textKey(invitation.id, (row) => row.id), + ] + const rows = await db + .select(invitationSelection) + .from(invitation) + .where( + and( + eq(invitation.organizationId, organizationId), + searchFilter(invitation.email, options.search), + options.status ? eq(invitationStatus, options.status) : undefined, + resumeKeyset(keys, options.cursorKeys, options.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), options.sortOrder)) + .limit(options.limit + 1) + return keysetPage(keys, rows, options.limit) +} + +export async function requireOrganizationInvitationRecord( + organizationId: string, + invitationId: string +) { + const [row] = await db + .select(invitationSelection) + .from(invitation) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.id, invitationId))) + .limit(1) + if (!row) throw new OrchestrationError('not_found', 'Invitation not found') + return row +} diff --git a/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts b/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts index 476fecbf796..4758fa97831 100644 --- a/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts +++ b/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts @@ -48,7 +48,8 @@ export function defineAuthorizedPermissionGroupUseCase< if ( error instanceof OrchestrationError && error.code === 'forbidden' && - !(error instanceof ForbiddenOperationError) + (!(error instanceof ForbiddenOperationError) || + error.detailCode === 'ORGANIZATION_ADMIN_REQUIRED') ) throw new ForbiddenOperationError( 'ORGANIZATION_ADMIN_REQUIRED', diff --git a/apps/sim/lib/permission-groups/application/use-cases.ts b/apps/sim/lib/permission-groups/application/use-cases.ts index 964c6b2042c..e4ac75bb96d 100644 --- a/apps/sim/lib/permission-groups/application/use-cases.ts +++ b/apps/sim/lib/permission-groups/application/use-cases.ts @@ -26,6 +26,7 @@ import { addPermissionGroupMemberRecord, type BulkAddPermissionGroupMembersInput, bulkAddPermissionGroupMemberRecords, + type PermissionGroupMemberTarget, removePermissionGroupMemberRecord, } from '@/lib/permission-groups/member-manager' import { getGroupWorkspaces } from '@/lib/permission-groups/repository' @@ -143,8 +144,8 @@ export const addPermissionGroupMember = defineAuthorizedPermissionGroupUseCase({ export const removePermissionGroupMember = defineAuthorizedPermissionGroupUseCase({ operation: permissionGroupOperations.removeMember, - execute: ({ input }: { input: PermissionGroupInput & { memberId: string } }) => - removePermissionGroupMemberRecord(input.organizationId, input.groupId, input.memberId), + execute: ({ input }: { input: PermissionGroupInput & PermissionGroupMemberTarget }) => + removePermissionGroupMemberRecord(input.organizationId, input.groupId, input), projectAudit: ({ result }) => ({ action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, resourceType: AuditResourceType.PERMISSION_GROUP, diff --git a/apps/sim/lib/permission-groups/member-manager.test.ts b/apps/sim/lib/permission-groups/member-manager.test.ts index f38ca1ac9bc..44e4c7f2d04 100644 --- a/apps/sim/lib/permission-groups/member-manager.test.ts +++ b/apps/sim/lib/permission-groups/member-manager.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment node */ import { member, permissionGroupMember } from '@sim/db/schema' -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -152,21 +152,57 @@ describe('permission-group membership mutations', () => { workspaceName: 'Engineering', }) await expect( - removePermissionGroupMemberRecord('org-1', 'group-1', 'assignment-1') + removePermissionGroupMemberRecord('org-1', 'group-1', { memberId: 'assignment-1' }) ).rejects.toMatchObject({ code: 'conflict' }) expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) it('can empty a group in explicit membership mode', async () => { mocks.group.mockResolvedValue({ ...group, membershipMode: 'explicit' }) queueTableRows(permissionGroupMember, [{ id: 'assignment-1', userId: 'member-1', email: null }]) - await removePermissionGroupMemberRecord('org-1', 'group-1', 'assignment-1') + await removePermissionGroupMemberRecord('org-1', 'group-1', { memberId: 'assignment-1' }) expect(mocks.allConflict).not.toHaveBeenCalled() expect(dbChainMockFns.delete).toHaveBeenCalledOnce() }) it('does not delete an assignment absent from the requested group', async () => { await expect( - removePermissionGroupMemberRecord('org-1', 'group-1', 'other-assignment') + removePermissionGroupMemberRecord('org-1', 'group-1', { memberId: 'other-assignment' }) ).rejects.toMatchObject({ code: 'not_found' }) expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) + it('looks up a scoped user and deletes the canonical assignment ID', async () => { + mocks.group.mockResolvedValue({ ...group, membershipMode: 'explicit' }) + queueTableRows(permissionGroupMember, [{ id: 'assignment-1', userId: 'user-1', email: null }]) + await removePermissionGroupMemberRecord('org-1', 'group-1', { userId: 'user-1' }) + const conditions = dbChainMockFns.where.mock.calls.map(([condition]) => condition) + expect( + conditions.some( + (condition) => + hasMockCondition( + condition, + (node) => + node.type === 'eq' && + node.left === permissionGroupMember.userId && + node.right === 'user-1' + ) && + hasMockCondition( + condition, + (node) => + node.type === 'eq' && + node.left === permissionGroupMember.permissionGroupId && + node.right === 'group-1' + ) + ) + ).toBe(true) + expect( + conditions.some((condition) => + hasMockCondition( + condition, + (node) => + node.type === 'eq' && + node.left === permissionGroupMember.id && + node.right === 'assignment-1' + ) + ) + ).toBe(true) + }) }) diff --git a/apps/sim/lib/permission-groups/member-manager.ts b/apps/sim/lib/permission-groups/member-manager.ts index 023770f91fd..e0138600405 100644 --- a/apps/sim/lib/permission-groups/member-manager.ts +++ b/apps/sim/lib/permission-groups/member-manager.ts @@ -63,10 +63,12 @@ export async function addPermissionGroupMemberRecord( }) } +export type PermissionGroupMemberTarget = { memberId: string } | { userId: string } + export async function removePermissionGroupMemberRecord( organizationId: string, groupId: string, - memberId: string + target: PermissionGroupMemberTarget ) { return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) @@ -80,7 +82,9 @@ export async function removePermissionGroupMemberRecord( .innerJoin(user, eq(permissionGroupMember.userId, user.id)) .where( and( - eq(permissionGroupMember.id, memberId), + 'memberId' in target + ? eq(permissionGroupMember.id, target.memberId) + : eq(permissionGroupMember.userId, target.userId), eq(permissionGroupMember.permissionGroupId, groupId) ) ) @@ -107,7 +111,7 @@ export async function removePermissionGroupMemberRecord( .delete(permissionGroupMember) .where( and( - eq(permissionGroupMember.id, memberId), + eq(permissionGroupMember.id, assignment.id), eq(permissionGroupMember.permissionGroupId, groupId) ) ) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 876319aa0e2..dc6632172ed 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,13 +1,17 @@ import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' -const PERMISSION_GROUP_ORGANIZATION_FLAG = { - organizationId: { name: 'organization', describe: 'Organization that owns the permission group' }, +const ORGANIZATION_FLAG = { + organizationId: { name: 'organization', describe: 'Organization identifier' }, } as const const PERMISSION_GROUP_MEMBER_FLAGS = { - ...PERMISSION_GROUP_ORGANIZATION_FLAG, + ...ORGANIZATION_FLAG, groupId: { name: 'group', describe: 'Permission group identifier' }, } as const +const DEFAULT_FLAG = { + isDefault: { name: 'default', boolean: true, negatable: true }, +} as const + const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' @@ -942,9 +946,71 @@ export const CLI_CONTRACT: CliContract = { { header: 'built-in', path: 'readOnly', format: 'bool' }, ], }, + listOrganizations: { + command: 'organizations list', + columns: [{ header: 'id' }, { header: 'name' }, { header: 'role' }], + }, + getOrganization: { command: 'organizations get' }, + listOrganizationWorkspaces: { + command: 'organizations workspaces', + pathFlags: ORGANIZATION_FLAG, + columns: [{ header: 'id' }, { header: 'name' }], + }, + listOrganizationMembers: { + command: 'organizations members list', + pathFlags: ORGANIZATION_FLAG, + columns: [ + { header: 'user', path: 'userId' }, + { header: 'name' }, + { header: 'email' }, + { header: 'role' }, + ], + }, + updateOrganizationMember: { + command: 'organizations members update', + pathFlags: ORGANIZATION_FLAG, + }, + removeOrganizationMember: { + command: 'organizations members remove', + pathFlags: ORGANIZATION_FLAG, + confirm: + 'This removes the member, revokes their organization workspace access, and ends their sessions.', + }, + listOrganizationInvitations: { + command: 'organizations invitations list', + pathFlags: ORGANIZATION_FLAG, + columns: [ + { header: 'id' }, + { header: 'email' }, + { header: 'role' }, + { header: 'membership', path: 'membershipIntent' }, + { header: 'status' }, + { header: 'expires', path: 'expiresAt', format: 'timestamp' }, + ], + }, + createOrganizationInvitation: { + command: 'organizations invitations create', + pathFlags: ORGANIZATION_FLAG, + }, + getOrganizationInvitation: { + command: 'organizations invitations get', + pathFlags: ORGANIZATION_FLAG, + }, + resendOrganizationInvitation: { + command: 'organizations invitations resend', + pathFlags: ORGANIZATION_FLAG, + }, + revokeOrganizationInvitation: { + command: 'organizations invitations revoke', + pathFlags: ORGANIZATION_FLAG, + confirm: 'This cancels the invitation and all its workspace grants, preventing acceptance.', + }, + updateTableView: { + flags: { isDefault: { ...DEFAULT_FLAG.isDefault, renamedFrom: ['is-default'] } }, + }, listPermissionGroups: { command: 'permission-groups list', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + pathFlags: ORGANIZATION_FLAG, columns: [ { header: 'id' }, { header: 'name' }, @@ -954,26 +1020,27 @@ export const CLI_CONTRACT: CliContract = { }, getPermissionGroup: { command: 'permission-groups get', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + pathFlags: ORGANIZATION_FLAG, }, createPermissionGroup: { command: 'permission-groups create', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + pathFlags: ORGANIZATION_FLAG, + flags: DEFAULT_FLAG, }, updatePermissionGroup: { command: 'permission-groups update', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + pathFlags: ORGANIZATION_FLAG, + flags: DEFAULT_FLAG, }, deletePermissionGroup: { command: 'permission-groups delete', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, + pathFlags: ORGANIZATION_FLAG, confirm: 'This permanently deletes the permission group and its member assignments.', }, listPermissionGroupMembers: { command: 'permission-groups members list', pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, columns: [ - { header: 'id' }, { header: 'user', path: 'userId' }, { header: 'name', path: 'userName' }, { header: 'email', path: 'userEmail' }, @@ -982,6 +1049,7 @@ export const CLI_CONTRACT: CliContract = { addPermissionGroupMember: { command: 'permission-groups members add', pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + flags: { userId: { name: 'user' } }, }, removePermissionGroupMember: { command: 'permission-groups members remove', @@ -992,11 +1060,18 @@ export const CLI_CONTRACT: CliContract = { bulkAddPermissionGroupMembers: { command: 'permission-groups members batch-add', pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, - }, - listPermissionGroupWorkspaces: { - command: 'permission-groups workspaces', - pathFlags: PERMISSION_GROUP_ORGANIZATION_FLAG, - columns: [{ header: 'id' }, { header: 'name' }], + flags: { + userIds: { + name: 'user', + list: true, + describe: 'User IDs to add; cannot be combined with --all-members', + }, + addAllOrganizationMembers: { + name: 'all-members', + boolean: true, + describe: 'Add every current organization member; cannot be combined with --user', + }, + }, }, listCustomTools: { columns: [ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index d2b5c7408cb..fa2639292b7 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1958,6 +1958,34 @@ export type CreateMcpServerResponse = { data: CreateMcpServerResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/invitations` */ +export type CreateOrganizationInvitationParams = { + organizationId: string +} + +export type CreateOrganizationInvitationQuery = Record + +export type CreateOrganizationInvitationBody = { + email: string + role?: 'member' | 'admin' +} + +type CreateOrganizationInvitationResponseRef0 = { + id: string + organizationId: string + email: string + role: 'member' | 'admin' + kind: 'organization' | 'workspace' + membershipIntent: 'internal' | 'external' + status: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + createdAt: string + expiresAt: string +} + +export type CreateOrganizationInvitationResponse = { + data: CreateOrganizationInvitationResponseRef0 +} + /** `POST /api/v2/organizations/[organizationId]/permission-groups` */ export type CreatePermissionGroupParams = { organizationId: string @@ -4841,6 +4869,50 @@ export type GetNextKnowledgeTagSlotResponse = { data: GetNextKnowledgeTagSlotResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]` */ +export type GetOrganizationParams = { + organizationId: string +} + +export type GetOrganizationQuery = Record + +type GetOrganizationResponseRef0 = { + id: string + name: string + slug: string + logo: string | null + role: 'owner' | 'admin' | 'member' + createdAt: string +} + +export type GetOrganizationResponse = { + data: GetOrganizationResponseRef0 +} + +/** `GET /api/v2/organizations/[organizationId]/invitations/[invitationId]` */ +export type GetOrganizationInvitationParams = { + organizationId: string + invitationId: string +} + +export type GetOrganizationInvitationQuery = Record + +type GetOrganizationInvitationResponseRef0 = { + id: string + organizationId: string + email: string + role: 'member' | 'admin' + kind: 'organization' | 'workspace' + membershipIntent: 'internal' | 'external' + status: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + createdAt: string + expiresAt: string +} + +export type GetOrganizationInvitationResponse = { + data: GetOrganizationInvitationResponseRef0 +} + /** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ export type GetPermissionGroupParams = { organizationId: string @@ -7011,6 +7083,109 @@ export type ListMcpServerToolsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/invitations` */ +export type ListOrganizationInvitationsParams = { + organizationId: string +} + +export type ListOrganizationInvitationsQuery = { + search?: string + status?: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + sortBy?: 'email' | 'createdAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListOrganizationInvitationsResponseRef0 = { + id: string + organizationId: string + email: string + role: 'member' | 'admin' + kind: 'organization' | 'workspace' + membershipIntent: 'internal' | 'external' + status: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + createdAt: string + expiresAt: string +} + +export type ListOrganizationInvitationsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations/[organizationId]/members` */ +export type ListOrganizationMembersParams = { + organizationId: string +} + +export type ListOrganizationMembersQuery = { + search?: string + sortBy?: 'name' | 'email' | 'joinedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListOrganizationMembersResponseRef0 = { + userId: string + name: string + email: string + role: 'owner' | 'admin' | 'member' + joinedAt: string +} + +export type ListOrganizationMembersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations` */ +export type ListOrganizationsQuery = { + search?: string + sortBy?: 'name' | 'createdAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListOrganizationsResponseRef0 = { + id: string + name: string + slug: string + logo: string | null + role: 'owner' | 'admin' | 'member' + createdAt: string +} + +export type ListOrganizationsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations/[organizationId]/workspaces` */ +export type ListOrganizationWorkspacesParams = { + organizationId: string +} + +export type ListOrganizationWorkspacesQuery = { + search?: string + sortBy?: 'name' | 'id' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListOrganizationWorkspacesResponseRef0 = { + id: string + name: string +} + +export type ListOrganizationWorkspacesResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members` */ export type ListPermissionGroupMembersParams = { organizationId: string @@ -7113,29 +7288,6 @@ export type ListPermissionGroupsResponse = { nextCursor: string | null } -/** `GET /api/v2/organizations/[organizationId]/permission-groups/workspaces` */ -export type ListPermissionGroupWorkspacesParams = { - organizationId: string -} - -export type ListPermissionGroupWorkspacesQuery = { - search?: string - sortBy?: 'name' | 'id' - sortOrder?: 'asc' | 'desc' - limit?: number - cursor?: string -} - -type ListPermissionGroupWorkspacesResponseRef0 = { - id: string - name: string -} - -export type ListPermissionGroupWorkspacesResponse = { - data: Array - nextCursor: string | null -} - /** `GET /api/v2/sandboxes` */ export type ListSandboxesQuery = { workspaceId: string @@ -9137,17 +9289,34 @@ export type RelocateWorkflowFolderResponse = { data: RelocateWorkflowFolderResponseRef0 } -/** `DELETE /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]` */ +/** `DELETE /api/v2/organizations/[organizationId]/members/[userId]` */ +export type RemoveOrganizationMemberParams = { + organizationId: string + userId: string +} + +export type RemoveOrganizationMemberQuery = Record + +type RemoveOrganizationMemberResponseRef0 = { + userId: string + deleted: true +} + +export type RemoveOrganizationMemberResponse = { + data: RemoveOrganizationMemberResponseRef0 +} + +/** `DELETE /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]` */ export type RemovePermissionGroupMemberParams = { organizationId: string groupId: string - memberId: string + userId: string } export type RemovePermissionGroupMemberQuery = Record type RemovePermissionGroupMemberResponseRef0 = { - id: string + userId: string deleted: true } @@ -9428,6 +9597,32 @@ export type ReplaceWorkflowStateResponse = { data: ReplaceWorkflowStateResponseRef1 } +/** `POST /api/v2/organizations/[organizationId]/invitations/[invitationId]/resend` */ +export type ResendOrganizationInvitationParams = { + organizationId: string + invitationId: string +} + +export type ResendOrganizationInvitationQuery = Record + +export type ResendOrganizationInvitationBody = Record + +type ResendOrganizationInvitationResponseRef0 = { + id: string + organizationId: string + email: string + role: 'member' | 'admin' + kind: 'organization' | 'workspace' + membershipIntent: 'internal' | 'external' + status: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + createdAt: string + expiresAt: string +} + +export type ResendOrganizationInvitationResponse = { + data: ResendOrganizationInvitationResponseRef0 +} + /** `POST /api/v2/files/[fileId]/restore` */ export type RestoreFileParams = { fileId: string @@ -9777,6 +9972,23 @@ export type RevertWorkflowVersionResponse = { data: RevertWorkflowVersionResponseRef0 } +/** `DELETE /api/v2/organizations/[organizationId]/invitations/[invitationId]` */ +export type RevokeOrganizationInvitationParams = { + organizationId: string + invitationId: string +} + +export type RevokeOrganizationInvitationQuery = Record + +type RevokeOrganizationInvitationResponseRef0 = { + id: string + status: 'cancelled' +} + +export type RevokeOrganizationInvitationResponse = { + data: RevokeOrganizationInvitationResponseRef0 +} + /** `DELETE /api/v2/skills/[skillId]/editors` */ export type RevokeSkillEditorParams = { skillId: string @@ -10698,6 +10910,30 @@ export type UpdateMcpServerResponse = { data: UpdateMcpServerResponseRef0 } +/** `PATCH /api/v2/organizations/[organizationId]/members/[userId]` */ +export type UpdateOrganizationMemberParams = { + organizationId: string + userId: string +} + +export type UpdateOrganizationMemberQuery = Record + +export type UpdateOrganizationMemberBody = { + role: 'member' | 'admin' +} + +type UpdateOrganizationMemberResponseRef0 = { + userId: string + name: string + email: string + role: 'owner' | 'admin' | 'member' + joinedAt: string +} + +export type UpdateOrganizationMemberResponse = { + data: UpdateOrganizationMemberResponseRef0 +} + /** `PATCH /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ export type UpdatePermissionGroupParams = { organizationId: string @@ -12655,6 +12891,25 @@ export const V2_OPERATIONS = { }, }, }, + createOrganizationInvitation: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/invitations', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'Create Organization Invitation', + workspaceKeyUnsupported: true, + body: { + email: { kind: 'string', required: true, describe: 'Email address of the person to invite.' }, + role: { + kind: 'enum', + values: ['member', 'admin'] as const, + default: 'member', + describe: + 'Organization role to offer. Defaults to member; grants no workspace-specific permissions.', + }, + }, + }, createPermissionGroup: { method: 'POST', path: '/api/v2/organizations/[organizationId]/permission-groups', @@ -12673,7 +12928,7 @@ export const V2_OPERATIONS = { config: { kind: 'object', describe: - 'Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list.', + 'Permission restrictions to set. Omitted keys use the default permission configuration.', }, isDefault: { kind: 'boolean', @@ -12683,7 +12938,7 @@ export const V2_OPERATIONS = { workspaceIds: { kind: 'array', describe: - 'Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.', + 'Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.', }, }, }, @@ -14082,6 +14337,27 @@ export const V2_OPERATIONS = { }, }, }, + getOrganization: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'Get Organization', + workspaceKeyUnsupported: true, + }, + getOrganizationInvitation: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', + pathParams: ['organizationId', 'invitationId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + invitationId: 'Invitation identifier.', + }, + responseMode: 'json', + summary: 'Get Organization Invitation', + workspaceKeyUnsupported: true, + }, getPermissionGroup: { method: 'GET', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', @@ -15611,35 +15887,41 @@ export const V2_OPERATIONS = { }, }, }, - listPermissionGroupMembers: { + listOrganizationInvitations: { method: 'GET', - path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', - pathParams: ['organizationId', 'groupId'] as const, - pathParamDocs: { - organizationId: 'Organization that owns the permission groups.', - groupId: 'Permission group identifier.', - }, + path: '/api/v2/organizations/[organizationId]/invitations', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, responseMode: 'json', - summary: 'List Permission Group Members', + summary: 'List Organization Invitations', workspaceKeyUnsupported: true, query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the invitee email.', + }, + status: { + kind: 'enum', + values: ['pending', 'accepted', 'rejected', 'cancelled', 'expired'] as const, + describe: 'Filter by current invitation status. Omit to include all statuses.', + }, sortBy: { kind: 'enum', - values: ['assignedAt', 'userId'] as const, - default: 'assignedAt', + values: ['email', 'createdAt'] as const, + default: 'createdAt', describe: 'Field used to sort the result.', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, - default: 'asc', + default: 'desc', describe: 'Sort direction.', }, limit: { kind: 'integer', default: 50, describe: - 'Maximum group members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + 'Maximum invitations to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, cursor: { kind: 'string', @@ -15648,37 +15930,37 @@ export const V2_OPERATIONS = { }, }, }, - listPermissionGroups: { + listOrganizationMembers: { method: 'GET', - path: '/api/v2/organizations/[organizationId]/permission-groups', + path: '/api/v2/organizations/[organizationId]/members', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + pathParamDocs: { organizationId: 'Organization identifier.' }, responseMode: 'json', - summary: 'List Permission Groups', + summary: 'List Organization Members', workspaceKeyUnsupported: true, query: { search: { kind: 'string', - describe: 'Case-insensitive substring match against the group name.', + describe: 'Case-insensitive substring match against member name or email.', }, sortBy: { kind: 'enum', - values: ['name', 'createdAt', 'updatedAt'] as const, - default: 'createdAt', + values: ['name', 'email', 'joinedAt'] as const, + default: 'name', 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', + default: 'asc', describe: 'Sort direction.', }, limit: { kind: 'integer', default: 50, describe: - 'Maximum permission groups to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + 'Maximum members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, cursor: { kind: 'string', @@ -15687,13 +15969,51 @@ export const V2_OPERATIONS = { }, }, }, - listPermissionGroupWorkspaces: { + listOrganizations: { method: 'GET', - path: '/api/v2/organizations/[organizationId]/permission-groups/workspaces', + path: '/api/v2/organizations', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Organizations', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the organization name.', + }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt'] as const, + default: 'name', + 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 organizations 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.', + }, + }, + }, + listOrganizationWorkspaces: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/workspaces', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + pathParamDocs: { organizationId: 'Organization identifier.' }, responseMode: 'json', - summary: 'List Permission Group Workspaces', + summary: 'List Organization Workspaces', workspaceKeyUnsupported: true, query: { search: { @@ -15717,7 +16037,83 @@ export const V2_OPERATIONS = { kind: 'integer', default: 50, describe: - 'Maximum organization workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + '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.', + }, + }, + }, + listPermissionGroupMembers: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', + pathParams: ['organizationId', 'groupId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + }, + responseMode: 'json', + summary: 'List Permission Group Members', + workspaceKeyUnsupported: true, + query: { + sortBy: { + kind: 'enum', + values: ['assignedAt', 'userId'] as const, + default: 'assignedAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum group members 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.', + }, + }, + }, + listPermissionGroups: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/permission-groups', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the permission groups.' }, + responseMode: 'json', + summary: 'List Permission Groups', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the group name.', + }, + 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 permission groups to return per page. Must be a whole number from 1 to 100. Defaults to 50.', }, cursor: { kind: 'string', @@ -17054,14 +17450,26 @@ export const V2_OPERATIONS = { }, }, }, + removeOrganizationMember: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/members/[userId]', + pathParams: ['organizationId', 'userId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + userId: 'User identifier of the organization member.', + }, + responseMode: 'json', + summary: 'Remove Organization Member', + workspaceKeyUnsupported: true, + }, removePermissionGroupMember: { method: 'DELETE', - path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[memberId]', - pathParams: ['organizationId', 'groupId', 'memberId'] as const, + path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]', + pathParams: ['organizationId', 'groupId', 'userId'] as const, pathParamDocs: { organizationId: 'Organization that owns the permission groups.', groupId: 'Permission group identifier.', - memberId: 'Membership assignment identifier returned by List Permission Group Members.', + userId: 'User identifier of the member to remove.', }, responseMode: 'json', summary: 'Remove Permission Group Member', @@ -17167,6 +17575,18 @@ export const V2_OPERATIONS = { }, }, }, + resendOrganizationInvitation: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend', + pathParams: ['organizationId', 'invitationId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + invitationId: 'Invitation identifier.', + }, + responseMode: 'json', + summary: 'Resend Organization Invitation', + workspaceKeyUnsupported: true, + }, restoreFile: { method: 'POST', path: '/api/v2/files/[fileId]/restore', @@ -17307,6 +17727,18 @@ export const V2_OPERATIONS = { summary: 'Revert Workflow To Version', workspaceKeyUnsupported: true, }, + revokeOrganizationInvitation: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', + pathParams: ['organizationId', 'invitationId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + invitationId: 'Invitation identifier.', + }, + responseMode: 'json', + summary: 'Revoke Organization Invitation', + workspaceKeyUnsupported: true, + }, revokeSkillEditor: { method: 'DELETE', path: '/api/v2/skills/[skillId]/editors', @@ -17960,6 +18392,26 @@ export const V2_OPERATIONS = { }, }, }, + updateOrganizationMember: { + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/members/[userId]', + pathParams: ['organizationId', 'userId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + userId: 'User identifier of the organization member.', + }, + responseMode: 'json', + summary: 'Update Organization Member', + workspaceKeyUnsupported: true, + body: { + role: { + kind: 'enum', + required: true, + values: ['member', 'admin'] as const, + describe: 'New organization role. Ownership transfers use a separate operation.', + }, + }, + }, updatePermissionGroup: { method: 'PATCH', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 0ada1461455..d243006041e 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1026,6 +1026,9 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'createOrganizationInvitation', + 'resendOrganizationInvitation', + 'updateOrganizationMember', 'createPermissionGroup', 'updatePermissionGroup', 'addPermissionGroupMember', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 32e7a2d5a34..7a7e9ff20a6 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -173,7 +173,7 @@ describe('commands parsed through commander', () => { 'org-1', '--group', 'group-1', - '--user-id', + '--user', 'user-1', ], { data: { id: 'assignment-1' } } @@ -196,6 +196,121 @@ describe('commands parsed through commander', () => { }) }) + it.each([ + ['--default', true], + ['--no-default', false], + ] as const)('maps %s to the default field', async (flag, value) => { + const [, options] = await run( + ['permission-groups', 'update', 'group-1', '--organization', 'org-1', flag], + { data: {} } + ) + expect(options.body).toEqual({ isDefault: value }) + }) + + it.each([ + [['--user', 'user-1', 'user-2'], { userIds: ['user-1', 'user-2'] }], + [['--all-members'], { addAllOrganizationMembers: true }], + ])('maps batch membership selection %j', async (flags, body) => { + const [, options] = await run( + [ + 'permission-groups', + 'members', + 'batch-add', + '--organization', + 'org-1', + '--group', + 'group-1', + ...flags, + ], + { data: { added: 2, skipped: 0 } } + ) + expect(options.body).toEqual(body) + }) + + it('removes a permission group member by user ID', async () => { + const [path, options] = await run( + [ + 'permission-groups', + 'members', + 'remove', + 'user-1', + '--organization', + 'org-1', + '--group', + 'group-1', + '--yes', + ], + { data: { userId: 'user-1', deleted: true } } + ) + expect(path).toBe('/api/v2/organizations/org-1/permission-groups/group-1/members/user-1') + expect(options.method).toBe('DELETE') + }) + + it('creates organization invitations without a workspace', async () => { + profileState.workspaceId = null + const [path, options] = await run( + [ + 'organizations', + 'invitations', + 'create', + '--organization', + 'org-1', + '--email', + 'person@example.com', + '--role', + 'admin', + ], + { data: {} } + ) + expect(path).toBe('/api/v2/organizations/org-1/invitations') + expect(options).toMatchObject({ + method: 'POST', + body: { email: 'person@example.com', role: 'admin' }, + }) + }) + + it('resends an invitation without requiring a JSON argument', async () => { + const [path, options] = await run( + ['organizations', 'invitations', 'resend', 'invite-1', '--organization', 'org-1'], + { data: {} } + ) + expect(path).toBe('/api/v2/organizations/org-1/invitations/invite-1/resend') + expect(options.method).toBe('POST') + }) + + it('updates organization roles using user IDs', async () => { + const [path, options] = await run( + [ + 'organizations', + 'members', + 'update', + 'user-1', + '--organization', + 'org-1', + '--role', + 'admin', + ], + { data: {} } + ) + expect(path).toBe('/api/v2/organizations/org-1/members/user-1') + expect(options).toMatchObject({ method: 'PATCH', body: { role: 'admin' } }) + }) + + it.each([ + ['--default', true], + ['--no-default', false], + ['--is-default', true], + ['--no-is-default', false], + ] as const)( + 'uses the same default flag for table views and preserves %s', + async (flag, value) => { + const [, options] = await run(['tables', 'views', 'update', 'table-1', 'view-1', flag], { + data: {}, + }) + expect(options.body).toEqual({ workspaceId: 'ws_local', isDefault: value }) + } + ) + it('carries a multi-word flag all the way to the request', async () => { // The regression: commander stores this as `minDurationMs`, so a lookup by // `min-duration-ms` found nothing and the filter never reached the API. diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 18397fe8616..555aa921ac1 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -22,7 +22,9 @@ const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listCustomTools', 'listPermissionGroups', 'listPermissionGroupMembers', - 'listPermissionGroupWorkspaces', + 'listOrganizations', + 'listOrganizationMembers', + 'listOrganizationWorkspaces', 'listFiles', 'listKnowledgeBases', 'listKnowledgeConnectors', @@ -184,6 +186,11 @@ function addFieldOption( if (!flag.boolean || flag.negatable) { command.option(`--no-${name}`, `Send --${name} as false`) } + for (const previous of flag.renamedFrom ?? []) { + command.addOption(new Option(`--${previous}`).hideHelp()) + if (!flag.boolean || flag.negatable) + command.addOption(new Option(`--no-${previous}`).hideHelp()) + } return } diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 957780771da..4f4e860275c 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -114,7 +114,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 53], ['apps/docs/openapi-v2-knowledge.json', 45], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 61], + ['apps/docs/openapi-v2-resources.json', 71], ]) const generatedDocuments = new Map<(typeof DOCUMENTS)[number], JsonObject>() @@ -310,7 +310,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(257) + expect(totalOperations).toBe(267) }) it('documents mixed workflow execution and resume responses', () => { From 79886188d1b9b844d836ee5cfe48b56003f9aa07 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 10:14:29 -0700 Subject: [PATCH 4/7] fix(docs): include organization endpoints in OpenAPI coverage --- apps/docs/lib/openapi-download.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 437bf2956a3..6cc51ca68d5 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,7 +33,7 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(163) + expect(Object.keys(paths)).toHaveLength(170) expect(tags.map((tag) => tag.name)).toEqual([ 'Workspace Sync', 'Workflows', @@ -44,6 +44,7 @@ describe('OpenAPI download', () => { 'Tables', 'Knowledge Bases', 'Billing', + 'Organizations', 'Permission Groups', 'Meta', 'Workspaces', From 22e5858e0b6e42268fc767fe6902b13f55b03804 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 10:40:13 -0700 Subject: [PATCH 5/7] fix(organizations): harden invitation delivery and mutation boundaries --- .../api/invitations/[id]/resend/route.test.ts | 62 ++++++--- .../app/api/v2/organizations/route.test.ts | 14 ++ .../lib/api/server/routes/organizations.ts | 3 + .../invitations/application/mutations.test.ts | 43 +++++- .../lib/invitations/application/mutations.ts | 6 +- apps/sim/lib/invitations/core.test.ts | 30 +++++ apps/sim/lib/invitations/core.ts | 12 +- apps/sim/lib/invitations/errors.ts | 8 ++ apps/sim/lib/invitations/mutation-manager.ts | 38 +++--- apps/sim/lib/invitations/send-resend.test.ts | 70 ++++++++-- apps/sim/lib/invitations/send.ts | 124 +++++++++++------- .../permission-groups/member-manager.test.ts | 14 ++ .../lib/permission-groups/member-manager.ts | 7 + 13 files changed, 340 insertions(+), 91 deletions(-) create mode 100644 apps/sim/lib/invitations/errors.ts diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts index f6ab18a40d0..84755d5f0d1 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -16,7 +16,7 @@ const { mockValidateInvitationsAllowed, mockSendInvitationEmail, mockPrepareInvitationResend, - mockPersistInvitationResend, + mockRevertInvitationResend, mockGetOrganizationSubscription, } = vi.hoisted(() => ({ MockInvitationsNotAllowedError: class extends Error { @@ -34,7 +34,7 @@ const { mockValidateInvitationsAllowed: vi.fn(), mockSendInvitationEmail: vi.fn(), mockPrepareInvitationResend: vi.fn(), - mockPersistInvitationResend: vi.fn(), + mockRevertInvitationResend: vi.fn(), mockGetOrganizationSubscription: vi.fn(), })) @@ -56,7 +56,7 @@ vi.mock('@/lib/invitations/core', () => ({ vi.mock('@/lib/invitations/send', () => ({ sendInvitationEmail: mockSendInvitationEmail, prepareInvitationResend: mockPrepareInvitationResend, - persistInvitationResend: mockPersistInvitationResend, + revertInvitationResend: mockRevertInvitationResend, })) vi.mock('@/lib/billing/core/organization', () => ({ isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, @@ -76,6 +76,8 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), })) +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { PreparedInvitationResend } from '@/lib/invitations/send' import { POST } from '@/app/api/invitations/[id]/resend/route' const mockGetSession = authMockFns.mockGetSession @@ -107,6 +109,16 @@ const workspaceInvitation = { grants: [{ workspaceId: 'workspace-1', permission: 'read' }], } +const preparedResend: PreparedInvitationResend = { + invitationId: workspaceInvitation.id, + organizationId: workspaceInvitation.organizationId, + tokenForEmail: 'token-2', + nextExpiresAt: new Date('2099-02-01'), + mutationUpdatedAt: new Date('2026-02-01'), + previousToken: workspaceInvitation.token, + previousExpiresAt: workspaceInvitation.expiresAt, +} + /** * A resend re-delivers a working link and pushes the expiry forward, so it is a * send: without the gate an organization that has withheld invitations still @@ -132,13 +144,9 @@ describe('POST /api/invitations/[id]/resend', () => { }) mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true }) mockValidateInvitationsAllowed.mockResolvedValue(undefined) - mockPrepareInvitationResend.mockResolvedValue({ - tokenForEmail: 'token-2', - nextToken: 'token-2', - nextExpiresAt: new Date('2026-09-30T00:00:00.000Z'), - }) + mockPrepareInvitationResend.mockResolvedValue(preparedResend) mockSendInvitationEmail.mockResolvedValue({ success: true }) - mockPersistInvitationResend.mockResolvedValue(undefined) + mockRevertInvitationResend.mockResolvedValue(true) }) it('resends when no group withholds invitations', async () => { @@ -149,6 +157,9 @@ describe('POST /api/invitations/[id]/resend', () => { workspaceId: 'workspace-1', }) expect(mockSendInvitationEmail).toHaveBeenCalled() + expect(mockPrepareInvitationResend.mock.invocationCallOrder[0]).toBeLessThan( + mockSendInvitationEmail.mock.invocationCallOrder[0] + ) }) /** @@ -167,7 +178,7 @@ describe('POST /api/invitations/[id]/resend', () => { details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) expect(mockSendInvitationEmail).not.toHaveBeenCalled() - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() }) /** @@ -219,7 +230,7 @@ describe('POST /api/invitations/[id]/resend', () => { expect(response.status).toBe(403) expect(mockSendInvitationEmail).not.toHaveBeenCalled() - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() }) /** @@ -254,7 +265,7 @@ describe('POST /api/invitations/[id]/resend', () => { expect(response.status).toBe(403) expect(mockSendInvitationEmail).not.toHaveBeenCalled() - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() }) /** @@ -299,14 +310,35 @@ describe('POST /api/invitations/[id]/resend', () => { status, expiresAt: new Date('2000-01-01'), }) - expect((await callResend()).status).toBe(409) + expect((await callResend()).status).toBe(400) expect(mockSendInvitationEmail).not.toHaveBeenCalled() } ) - it('leaves the token unchanged when delivery fails', async () => { + it('restores the previous token when delivery fails', async () => { mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'Delivery unavailable' }) expect((await callResend()).status).toBe(502) - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).toHaveBeenCalledWith(preparedResend) + }) + + it('does not deliver a token when a concurrent change prevents persistence', async () => { + mockPrepareInvitationResend.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Invitation changed') + ) + expect((await callResend()).status).toBe(409) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() + }) + + it('compensates when delivery throws', async () => { + mockSendInvitationEmail.mockRejectedValueOnce(new Error('Mail transport unavailable')) + expect((await callResend()).status).toBe(502) + expect(mockRevertInvitationResend).toHaveBeenCalledOnce() + }) + + it('reports a conflict when failed delivery cannot be compensated over newer state', async () => { + mockSendInvitationEmail.mockResolvedValueOnce({ success: false }) + mockRevertInvitationResend.mockResolvedValueOnce(false) + expect((await callResend()).status).toBe(409) }) }) diff --git a/apps/sim/app/api/v2/organizations/route.test.ts b/apps/sim/app/api/v2/organizations/route.test.ts index 7f5c3b09e7a..a29caac991a 100644 --- a/apps/sim/app/api/v2/organizations/route.test.ts +++ b/apps/sim/app/api/v2/organizations/route.test.ts @@ -39,6 +39,11 @@ vi.mock('@/lib/invitations/mutation-manager', () => ({ })) import { dispatchMcpOperation } from '@/lib/api/mcp/dispatch' +import { + internalOrganizationErrorPolicy, + v2OrganizationErrorPolicy, +} from '@/lib/api/server/routes/organizations' +import { InvitationNotPendingError } from '@/lib/invitations/errors' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route' @@ -92,6 +97,15 @@ beforeEach(() => { }) describe('organization invitation API and MCP', () => { + it.each(['resend', 'revoke'] as const)( + 'preserves internal validation status for %s while exposing public conflict status', + (action) => { + const error = new InvitationNotPendingError(action) + expect(internalOrganizationErrorPolicy.project(error)?.status).toBe(400) + expect(v2OrganizationErrorPolicy.render(error)?.status).toBe(409) + } + ) + it.each([undefined, {}])( 'accepts a bodyless or empty resend and never returns tokens', async (body) => { diff --git a/apps/sim/lib/api/server/routes/organizations.ts b/apps/sim/lib/api/server/routes/organizations.ts index 248ed80f6a5..6327fd84a62 100644 --- a/apps/sim/lib/api/server/routes/organizations.ts +++ b/apps/sim/lib/api/server/routes/organizations.ts @@ -7,6 +7,7 @@ import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' import { isRetryableTransactionError } from '@/lib/db/transaction' +import { InvitationNotPendingError } from '@/lib/invitations/errors' import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' import { CAPABILITY_RULES, capabilityRefusal } from '@/lib/permission-groups/capabilities' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' @@ -15,6 +16,8 @@ import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission export const internalOrganizationErrorPolicy = extendInternalErrorPolicy( internalOrchestrationErrorPolicy, (error) => { + if (error instanceof InvitationNotPendingError) + return internalErrorResponse(400, { error: error.message }) if (error instanceof OrganizationMembershipNotFoundError) return internalErrorResponse(403, { error: 'Forbidden - Not a member of this organization' }) if (error instanceof ForbiddenOperationError) diff --git a/apps/sim/lib/invitations/application/mutations.test.ts b/apps/sim/lib/invitations/application/mutations.test.ts index ef650e1bc5f..c622e55e7ab 100644 --- a/apps/sim/lib/invitations/application/mutations.test.ts +++ b/apps/sim/lib/invitations/application/mutations.test.ts @@ -154,7 +154,48 @@ describe('shared invitation administration', () => { } ) - it('does not audit a resend rejected after email delivery', async () => { + it.each(['resend', 'revoke'] as const)( + 'keeps organization %s audits outside workspace scope', + async (action) => { + const organizationInvitation = { ...inv, kind: 'organization' } + mocks.invitation.mockResolvedValue(organizationInvitation) + mocks.resend.mockResolvedValue(organizationInvitation) + mocks.revoke.mockResolvedValue({ + success: true, + invitation: organizationInvitation, + invitationCancelled: true, + }) + const useCase = action === 'resend' ? resendInvitation : revokeInvitation + await useCase.execute({ principal: session, input }) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: null, + resourceId: 'org', + resourceType: 'organization', + }) + ) + } + ) + + it('keeps explicitly scoped organization-invitation revocation on the selected workspace', async () => { + const organizationInvitation = { ...inv, kind: 'organization' } + mocks.invitation.mockResolvedValue(organizationInvitation) + mocks.revoke.mockResolvedValue({ + success: true, + invitation: organizationInvitation, + invitationCancelled: false, + }) + await revokeInvitation.execute({ principal: session, input: { ...input, workspaceId: 'two' } }) + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'two', + resourceId: 'two', + resourceType: 'workspace', + }) + ) + }) + + it('does not audit a failed resend', async () => { mocks.resend.mockRejectedValue(new OrchestrationError('conflict', 'Invitation changed')) await expect(resendInvitation.execute({ principal: session, input })).rejects.toMatchObject({ code: 'conflict', diff --git a/apps/sim/lib/invitations/application/mutations.ts b/apps/sim/lib/invitations/application/mutations.ts index 0776e9f8b9d..ab01b731ce1 100644 --- a/apps/sim/lib/invitations/application/mutations.ts +++ b/apps/sim/lib/invitations/application/mutations.ts @@ -29,7 +29,7 @@ export const resendInvitation: OperationUseCase< }) recordProjectedUseCaseAuditEntries( invitationOperations.resend, - invitation.grants[0]?.workspaceId ?? null, + invitation.kind === 'organization' ? null : (invitation.grants[0]?.workspaceId ?? null), principal, request, [ @@ -73,7 +73,9 @@ export const revokeInvitation: OperationUseCase< const { actorUserId } = await authorizeInvitationMutation(principal, input, 'revoke') const result = await revokeInvitationRecord({ ...input, actorUserId }) const inv = result.invitation - const workspaceId = input.workspaceId ?? inv.grants[0]?.workspaceId ?? null + const workspaceId = + input.workspaceId ?? + (inv.kind === 'organization' ? null : (inv.grants[0]?.workspaceId ?? null)) recordProjectedUseCaseAuditEntries( invitationOperations.revoke, workspaceId, diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index fdc6572f97d..d9d796e5e34 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -2197,6 +2197,36 @@ describe('locked invitation mutations', () => { ) }) + it.each([undefined, 'workspace-1'])( + 'DELETE refuses expiry while waiting for authority locks (scope %s)', + async (workspaceId) => { + const now = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + queueWhereResponses([ + ...invitationHydrationRows(), + ...invitationHydrationRows(), + [{ id: 'member-1', role: 'admin' }], + ]) + dbChainMockFns.for.mockImplementationOnce(() => { + clock.mockReturnValue(now + 120_000) + return dbChainMock + }) + await expect( + revokeInvitationAsAdmin({ + actorId: 'admin-1', + invitationId: 'inv-1', + workspaceId, + }) + ).resolves.toEqual({ success: false, kind: 'not-pending' }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + } finally { + clock.mockRestore() + } + } + ) + it('PATCH role update observes an organization-admin demotion before mutating', async () => { queueWhereResponses([ ...invitationHydrationRows(), diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 3ac19c61db5..44e18aa1ff4 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1778,6 +1778,8 @@ export async function revokeInvitationAsAdmin(input: { return { success: false, kind: 'scoped-forbidden' } } + if (inv.expiresAt.getTime() <= Date.now()) return { success: false, kind: 'not-pending' } + const revoked = await revokeInvitationWorkspaceGrantTx(tx, { invitationId: input.invitationId, workspaceId: input.workspaceId, @@ -1809,10 +1811,18 @@ export async function revokeInvitationAsAdmin(input: { } } + if (inv.expiresAt.getTime() <= Date.now()) return { success: false, kind: 'not-pending' } + const cancelled = await tx .update(invitation) .set({ status: 'cancelled', updatedAt: new Date() }) - .where(and(eq(invitation.id, input.invitationId), eq(invitation.status, 'pending'))) + .where( + and( + eq(invitation.id, input.invitationId), + eq(invitation.status, 'pending'), + sql`${invitation.expiresAt} > clock_timestamp()` + ) + ) .returning({ id: invitation.id }) if (cancelled.length === 0) return { success: false, kind: 'not-cancellable' } diff --git a/apps/sim/lib/invitations/errors.ts b/apps/sim/lib/invitations/errors.ts new file mode 100644 index 00000000000..ea4c5be3129 --- /dev/null +++ b/apps/sim/lib/invitations/errors.ts @@ -0,0 +1,8 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export class InvitationNotPendingError extends OrchestrationError { + constructor(action: 'resend' | 'revoke') { + super('conflict', `Can only ${action} unexpired pending invitations`) + this.name = 'InvitationNotPendingError' + } +} diff --git a/apps/sim/lib/invitations/mutation-manager.ts b/apps/sim/lib/invitations/mutation-manager.ts index d8f9ba09f00..1858ff2b579 100644 --- a/apps/sim/lib/invitations/mutation-manager.ts +++ b/apps/sim/lib/invitations/mutation-manager.ts @@ -1,5 +1,6 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' @@ -12,9 +13,10 @@ import { resolveInvitationAdmissionOrganizationId, revokeInvitationAsAdmin, } from '@/lib/invitations/core' +import { InvitationNotPendingError } from '@/lib/invitations/errors' import { - persistInvitationResend, prepareInvitationResend, + revertInvitationResend, sendInvitationEmail, } from '@/lib/invitations/send' import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' @@ -22,6 +24,8 @@ import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' +const logger = createLogger('InvitationMutationManager') + export async function resendInvitationRecord(input: { invitation: InvitationWithGrants actorUserId: string @@ -29,10 +33,7 @@ export async function resendInvitationRecord(input: { }) { const inv = input.invitation if (inv.status !== 'pending' || inv.expiresAt.getTime() <= Date.now()) - throw new OrchestrationError( - 'conflict', - 'Can only resend unexpired pending invitations. Create a new invitation after expiration.' - ) + throw new InvitationNotPendingError('resend') /** permission-group-enforced: invitations.send — resend rechecks organization admission and every workspace grant. */ const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) if (admissionOrganizationId) @@ -80,7 +81,9 @@ export async function resendInvitationRecord(input: { const resend = await prepareInvitationResend({ invitationId: inv.id, currentToken: inv.token, - rotateToken: true, + expectedOrganizationId: input.assertedOrganizationId, + expectedUpdatedAt: inv.updatedAt, + actorUserId: input.actorUserId, }) const delivered = await sendInvitationEmail({ invitationId: inv.id, @@ -94,20 +97,19 @@ export async function resendInvitationRecord(input: { workspaceId: grant.workspaceId, permission: grant.permission, })), + }).catch((error: unknown) => { + logger.error('Invitation resend delivery failed', { invitationId: inv.id, error }) + return { success: false } }) - if (!delivered.success) + if (!delivered.success) { + const reverted = await revertInvitationResend(resend) throw new WorkspaceInvitationError({ - status: 502, - message: delivered.error || 'Failed to send invitation email', + status: reverted ? 502 : 409, + message: reverted + ? 'Failed to send invitation email. Please try again.' + : 'The invitation changed while delivery failed. Refresh before resending.', }) - await persistInvitationResend({ - invitationId: inv.id, - nextToken: resend.nextToken, - nextExpiresAt: resend.nextExpiresAt, - expectedOrganizationId: input.assertedOrganizationId, - expectedUpdatedAt: inv.updatedAt, - actorUserId: input.actorUserId, - }) + } return { id: inv.id, organizationId: inv.organizationId, @@ -149,7 +151,7 @@ export async function revokeInvitationRecord(input: { 'validation', 'Invitation does not grant access to that workspace' ) - throw new OrchestrationError('conflict', 'Can only revoke unexpired pending invitations') + throw new InvitationNotPendingError('revoke') } return result } diff --git a/apps/sim/lib/invitations/send-resend.test.ts b/apps/sim/lib/invitations/send-resend.test.ts index 9e331395711..52f0a431989 100644 --- a/apps/sim/lib/invitations/send-resend.test.ts +++ b/apps/sim/lib/invitations/send-resend.test.ts @@ -11,7 +11,7 @@ vi.mock('@/lib/invitations/core', async (original) => ({ })) import { ForbiddenOperationError } from '@/lib/core/application/forbidden' -import { persistInvitationResend } from '@/lib/invitations/send' +import { prepareInvitationResend, revertInvitationResend } from '@/lib/invitations/send' const revision = new Date('2026-01-01') const input = { @@ -19,8 +19,7 @@ const input = { actorUserId: 'actor', expectedOrganizationId: 'org', expectedUpdatedAt: revision, - nextToken: 'new-token', - nextExpiresAt: new Date('2099-02-01'), + currentToken: 'original-token', } beforeEach(() => { vi.resetAllMocks() @@ -28,14 +27,27 @@ beforeEach(() => { mocks.lock.mockResolvedValue({ id: 'inv', organizationId: 'org', + status: 'pending', + token: 'original-token', + updatedAt: revision, expiresAt: new Date('2099-01-01'), grants: [], }) dbChainMockFns.returning.mockResolvedValue([{ id: 'inv' }]) }) -describe('resend token persistence', () => { +describe('resend preparation and compensation', () => { it('rechecks authority under the invitation locks and conditionally updates the original pending revision', async () => { - await persistInvitationResend(input) + const prepared = await prepareInvitationResend(input) + expect(prepared).toMatchObject({ + invitationId: 'inv', + previousToken: 'original-token', + previousExpiresAt: new Date('2099-01-01'), + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + token: prepared.tokenForEmail, + expiresAt: prepared.nextExpiresAt, + updatedAt: prepared.mutationUpdatedAt, + }) expect(mocks.lock).toHaveBeenCalledWith(expect.anything(), 'inv', { lockCurrentGrantWorkspaces: true, }) @@ -51,6 +63,7 @@ describe('resend token persistence', () => { const [predicate] = dbChainMockFns.where.mock.calls[0] for (const [column, value] of [ [invitation.status, 'pending'], + [invitation.token, 'original-token'], [invitation.updatedAt, revision], [invitation.organizationId, 'org'], ]) @@ -64,22 +77,59 @@ describe('resend token persistence', () => { it('rejects a concurrent revision change or acceptance', async () => { dbChainMockFns.returning.mockResolvedValue([]) - await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) }) it('rejects canonical organization changes without touching the token', async () => { mocks.lock.mockResolvedValue({ organizationId: 'different' }) - await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'not_found' }) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'not_found' }) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('rejects demotion and expiration during delivery', async () => { + it('rejects demotion and expiration before delivery', async () => { mocks.authority.mockRejectedValueOnce( new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') ) - await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'forbidden' }) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'forbidden' }) mocks.lock.mockResolvedValue({ organizationId: 'org', expiresAt: new Date('2000-01-01') }) - await expect(persistInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('restores the previous token and expiry after failed delivery', async () => { + const prepared = await prepareInvitationResend(input) + mocks.lock.mockResolvedValue({ + id: 'inv', + status: 'pending', + organizationId: prepared.organizationId, + token: prepared.tokenForEmail, + updatedAt: prepared.mutationUpdatedAt, + }) + await expect(revertInvitationResend(prepared)).resolves.toBe(true) + expect(dbChainMockFns.set).toHaveBeenLastCalledWith({ + token: 'original-token', + expiresAt: new Date('2099-01-01'), + updatedAt: expect.any(Date), + }) + }) + + it.each([ + { status: 'accepted' }, + { organizationId: 'other-org' }, + { token: 'newer-resend-token' }, + { updatedAt: new Date('2099-02-01') }, + ])('does not compensate over a later invitation change: %j', async (change) => { + const prepared = await prepareInvitationResend(input) + mocks.lock.mockResolvedValue({ + id: 'inv', + status: 'pending', + organizationId: prepared.organizationId, + token: prepared.tokenForEmail, + updatedAt: prepared.mutationUpdatedAt, + ...change, + }) + dbChainMockFns.update.mockClear() + await expect(revertInvitationResend(prepared)).resolves.toBe(false) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index a075978dcd3..e89d73642fd 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -28,6 +28,7 @@ import { lockInvitationForMutation, requireInvitationResendAuthority, } from '@/lib/invitations/core' +import { InvitationNotPendingError } from '@/lib/invitations/errors' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -694,51 +695,25 @@ export async function sendWorkspaceAddedEmail( return { success: true } } -export async function prepareInvitationResend(params: { +export interface PreparedInvitationResend { invitationId: string - rotateToken?: boolean - currentToken: string -}): Promise<{ tokenForEmail: string; nextExpiresAt: Date; nextToken: string | null }> { - const nextExpiresAt = computeInvitationExpiry() - const nextToken = params.rotateToken ? generateId() : null - const tokenForEmail = nextToken ?? params.currentToken - return { tokenForEmail, nextExpiresAt, nextToken } + organizationId: string | null + tokenForEmail: string + nextExpiresAt: Date + mutationUpdatedAt: Date + previousToken: string + previousExpiresAt: Date } -export async function persistInvitationResend(params: { +/** Commits the resend token before delivery; stale requests never send an unsaved link. */ +export async function prepareInvitationResend(params: { invitationId: string - nextToken: string | null - nextExpiresAt: Date + currentToken: string expectedOrganizationId?: string expectedUpdatedAt: Date actorUserId: string -}): Promise { - async function persist(executor: DbOrTx) { - const [row] = await executor - .update(invitation) - .set({ - expiresAt: params.nextExpiresAt, - updatedAt: new Date(), - ...(params.nextToken ? { token: params.nextToken } : {}), - }) - .where( - and( - eq(invitation.id, params.invitationId), - eq(invitation.status, 'pending'), - params.expectedOrganizationId === undefined - ? undefined - : eq(invitation.organizationId, params.expectedOrganizationId), - eq(invitation.updatedAt, params.expectedUpdatedAt) - ) - ) - .returning({ id: invitation.id }) - if (!row) - throw new OrchestrationError( - 'conflict', - 'The invitation changed while the email was being delivered. Refresh before resending.' - ) - } - await db.transaction(async (tx) => { +}): Promise { + return db.transaction(async (tx) => { const current = await lockInvitationForMutation(tx, params.invitationId, { lockCurrentGrantWorkspaces: true, }) @@ -754,15 +729,76 @@ export async function persistInvitationResend(params: { params.actorUserId, params.expectedOrganizationId ) - if (current.expiresAt.getTime() <= Date.now()) + if (current.status !== 'pending' || current.expiresAt.getTime() <= Date.now()) + throw new InvitationNotPendingError('resend') + + const nextToken = generateId() + const nextExpiresAt = computeInvitationExpiry() + const mutationUpdatedAt = new Date() + const [row] = await tx + .update(invitation) + .set({ token: nextToken, expiresAt: nextExpiresAt, updatedAt: mutationUpdatedAt }) + .where( + and( + eq(invitation.id, params.invitationId), + eq(invitation.status, 'pending'), + eq(invitation.token, params.currentToken), + eq(invitation.updatedAt, params.expectedUpdatedAt), + sql`${invitation.expiresAt} > clock_timestamp()`, + params.expectedOrganizationId === undefined + ? undefined + : eq(invitation.organizationId, params.expectedOrganizationId) + ) + ) + .returning({ id: invitation.id }) + if (!row) throw new OrchestrationError( 'conflict', - 'The invitation expired while the email was being delivered. Create a new invitation.' + 'The invitation changed before it could be resent. Refresh before resending.' ) - await persist(tx) + return { + invitationId: current.id, + organizationId: current.organizationId, + tokenForEmail: nextToken, + nextExpiresAt, + mutationUpdatedAt, + previousToken: current.token, + previousExpiresAt: current.expiresAt, + } }) - logger.info('Persisted invitation resend', { - invitationId: params.invitationId, - rotated: !!params.nextToken, +} + +/** Restores a failed resend only while its exact pending revision still owns the token. */ +export async function revertInvitationResend(prepared: PreparedInvitationResend): Promise { + return db.transaction(async (tx) => { + const current = await lockInvitationForMutation(tx, prepared.invitationId) + if ( + !current || + current.status !== 'pending' || + current.organizationId !== prepared.organizationId || + current.token !== prepared.tokenForEmail || + current.updatedAt.getTime() !== prepared.mutationUpdatedAt.getTime() + ) + return false + const restored = await tx + .update(invitation) + .set({ + token: prepared.previousToken, + expiresAt: prepared.previousExpiresAt, + updatedAt: new Date(), + }) + .where( + and( + eq(invitation.id, prepared.invitationId), + eq(invitation.status, 'pending'), + eq(invitation.token, prepared.tokenForEmail), + eq(invitation.updatedAt, prepared.mutationUpdatedAt), + prepared.organizationId === null + ? sql`${invitation.organizationId} IS NULL` + : eq(invitation.organizationId, prepared.organizationId) + ) + ) + .returning({ id: invitation.id }) + return restored.length > 0 }) } diff --git a/apps/sim/lib/permission-groups/member-manager.test.ts b/apps/sim/lib/permission-groups/member-manager.test.ts index 44e4c7f2d04..3232eacfd6d 100644 --- a/apps/sim/lib/permission-groups/member-manager.test.ts +++ b/apps/sim/lib/permission-groups/member-manager.test.ts @@ -40,6 +40,20 @@ beforeEach(() => { }) describe('permission-group membership mutations', () => { + it.each([ + {}, + { userIds: [] }, + { addAllOrganizationMembers: false }, + { addAllOrganizationMembers: true, userIds: [] }, + { addAllOrganizationMembers: true, userIds: ['member-1'] }, + ])('rejects an ambiguous or empty bulk selection before mutation: %j', async (input) => { + await expect( + bulkAddPermissionGroupMemberRecords('org-1', 'group-1', input, 'admin-1') + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.group).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('requires organization membership for a single addition', async () => { await expect( addPermissionGroupMemberRecord('org-1', 'group-1', 'outsider', 'admin-1') diff --git a/apps/sim/lib/permission-groups/member-manager.ts b/apps/sim/lib/permission-groups/member-manager.ts index e0138600405..3ce9401b534 100644 --- a/apps/sim/lib/permission-groups/member-manager.ts +++ b/apps/sim/lib/permission-groups/member-manager.ts @@ -130,6 +130,13 @@ export async function bulkAddPermissionGroupMemberRecords( input: BulkAddPermissionGroupMembersInput, actorUserId: string ) { + if ( + input.addAllOrganizationMembers === true ? input.userIds !== undefined : !input.userIds?.length + ) + throw new OrchestrationError( + 'validation', + 'Provide userIds or set addAllOrganizationMembers to true, but not both' + ) return withPermissionGroupMutation(organizationId, async (tx) => { const group = await requirePermissionGroup(organizationId, groupId, tx) const workspaceIds = (await getGroupWorkspaces(groupId, tx)).map((workspace) => workspace.id) From d520146b60ecc433752d3cc3dc2e5dbc7a92c1b8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 10:57:59 -0700 Subject: [PATCH 6/7] fix(invitations): revalidate policy and expiry under mutation locks --- .../api/invitations/[id]/resend/route.test.ts | 92 +++++++++---- .../utils/permission-check.test.ts | 39 +++++- .../access-control/utils/permission-check.ts | 12 +- apps/sim/lib/invitations/core.ts | 12 +- .../lib/invitations/grant-revocation.test.ts | 28 +++- apps/sim/lib/invitations/mutation-manager.ts | 51 +------ .../sim/lib/invitations/resend-policy.test.ts | 126 ++++++++++++++++++ apps/sim/lib/invitations/resend-policy.ts | 97 ++++++++++++++ apps/sim/lib/invitations/send-resend.test.ts | 54 ++++++-- apps/sim/lib/invitations/send.ts | 22 +-- .../lib/permission-groups/resolve.server.ts | 27 ++-- apps/sim/lib/workspaces/policy.ts | 17 ++- 12 files changed, 463 insertions(+), 114 deletions(-) create mode 100644 apps/sim/lib/invitations/resend-policy.test.ts create mode 100644 apps/sim/lib/invitations/resend-policy.ts diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts index 84755d5f0d1..14f7f9a4e0d 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { member, user } from '@sim/db/schema' import { authMockFns, createMockRequest, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,6 +53,7 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ vi.mock('@/lib/invitations/core', () => ({ getInvitationById: mockGetInvitationById, resolveInvitationAdmissionOrganizationId: mockResolveInvitationAdmissionOrganizationId, + requireInvitationResendAuthority: vi.fn(), })) vi.mock('@/lib/invitations/send', () => ({ sendInvitationEmail: mockSendInvitationEmail, @@ -70,6 +72,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ })) vi.mock('@/lib/workspaces/policy', () => ({ getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, + WORKSPACE_MODE: { ORGANIZATION: 'organization' }, })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ @@ -77,6 +80,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ })) import { OrchestrationError } from '@/lib/core/orchestration/types' +import { lockInvitationResendPolicy } from '@/lib/invitations/resend-policy' import type { PreparedInvitationResend } from '@/lib/invitations/send' import { POST } from '@/app/api/invitations/[id]/resend/route' @@ -141,10 +145,21 @@ describe('POST /api/invitations/[id]/resend', () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', organizationId: 'organization-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner', + ownerId: 'owner', }) mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true }) mockValidateInvitationsAllowed.mockResolvedValue(undefined) - mockPrepareInvitationResend.mockResolvedValue(preparedResend) + mockPrepareInvitationResend.mockImplementation(async (params) => { + await lockInvitationResendPolicy( + db, + await mockGetInvitationById(params.invitationId), + params.actorUserId, + params.expectedOrganizationId + ) + return preparedResend + }) mockSendInvitationEmail.mockResolvedValue({ success: true }) mockRevertInvitationResend.mockResolvedValue(true) }) @@ -153,9 +168,13 @@ describe('POST /api/invitations/[id]/resend', () => { const response = await callResend() expect(response.status).toBe(200) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - workspaceId: 'workspace-1', - }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + workspaceId: 'workspace-1', + }, + db + ) expect(mockSendInvitationEmail).toHaveBeenCalled() expect(mockPrepareInvitationResend.mock.invocationCallOrder[0]).toBeLessThan( mockSendInvitationEmail.mock.invocationCallOrder[0] @@ -210,12 +229,20 @@ describe('POST /api/invitations/[id]/resend', () => { const response = await callResend() expect(response.status).toBe(200) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - organizationId: 'organization-1', - }) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - workspaceId: 'workspace-1', - }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + organizationId: 'organization-1', + }, + db + ) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + workspaceId: 'workspace-1', + }, + db + ) }) it('refuses an organization invitation the organization default group withholds, even when its granted workspace allows', async () => { @@ -245,13 +272,24 @@ describe('POST /api/invitations/[id]/resend', () => { const response = await callResend() expect(response.status).toBe(200) - expect(mockResolveInvitationAdmissionOrganizationId).toHaveBeenCalledWith(workspaceInvitation) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - organizationId: 'organization-1', - }) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - workspaceId: 'workspace-1', - }) + expect(mockResolveInvitationAdmissionOrganizationId).toHaveBeenCalledWith( + workspaceInvitation, + db + ) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + organizationId: 'organization-1', + }, + db + ) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + workspaceId: 'workspace-1', + }, + db + ) }) it('refuses a workspace invitation whose admitting organization withholds invitations', async () => { @@ -281,9 +319,13 @@ describe('POST /api/invitations/[id]/resend', () => { expect(response.status).toBe(200) expect(mockValidateInvitationsAllowed).toHaveBeenCalledTimes(1) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - workspaceId: 'workspace-1', - }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + workspaceId: 'workspace-1', + }, + db + ) }) it('resolves the organization default group for an invitation with no grants', async () => { @@ -298,9 +340,13 @@ describe('POST /api/invitations/[id]/resend', () => { const response = await callResend() expect(response.status).toBe(200) - expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { - organizationId: 'organization-1', - }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith( + 'user-1', + { + organizationId: 'organization-1', + }, + db + ) }) it.each(['pending', 'expired'])( 'rejects an expired %s invitation consistently', diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index c533905d353..20b59c2cdc3 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { permissionGroup } from '@sim/db/schema' import { envFlagsMockFns, @@ -49,6 +50,7 @@ import { CustomToolsNotAllowedError, getUserPermissionConfig, IntegrationNotAllowedError, + InvitationsNotAllowedError, McpToolsNotAllowedError, ModelNotAllowedError, ProviderNotAllowedError, @@ -57,9 +59,10 @@ import { ToolNotAllowedError, validateBlockType, validateChatDeployAuth, + validateInvitationsAllowed, validateModelProvider, validatePublicFileSharing, -} from './permission-check' +} from '@/ee/access-control/utils/permission-check' /** Default an org-backed, enterprise-entitled workspace so resolution reaches the group queries. */ function setEnterpriseOrgWorkspace() { @@ -902,3 +905,37 @@ describe('assertPermissionsAllowed', () => { }) }) }) + +describe('transactional invitation permission checks', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isAccessControlEnabled: true, isHosted: true, isInvitationsDisabled: false }) + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + setEnterpriseOrgWorkspace() + }) + + it('bypasses a cached allow decision when the transaction sees a newly restricted workspace', async () => { + await withPermissionGroupScope(async () => { + queueGroupResolution([], [{ config: { disableInvitations: false } }]) + await validateInvitationsAllowed('actor', { workspaceId: 'workspace-1' }) + queueGroupResolution([], [{ config: { disableInvitations: true } }]) + await expect( + validateInvitationsAllowed('actor', { workspaceId: 'workspace-1' }, db) + ).rejects.toBeInstanceOf(InvitationsNotAllowedError) + }) + expect(mockGetWorkspaceWithOwner).toHaveBeenLastCalledWith('workspace-1', { + includeArchived: true, + executor: db, + }) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenLastCalledWith('org-1', db) + }) + + it('resolves organization admission on the transaction executor', async () => { + queueTableRows(permissionGroup, [{ config: { disableInvitations: true } }]) + await expect( + validateInvitationsAllowed('actor', { organizationId: 'org-1' }, db) + ).rejects.toBeInstanceOf(InvitationsNotAllowedError) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-1', db) + }) +}) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 4dc5b900f8b..03c35cd1e31 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -10,6 +10,7 @@ import { } from '@/lib/core/config/env-flags' import { findDatabaseQueryError } from '@/lib/core/errors/database-query-error' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' +import type { DbOrTx } from '@/lib/db/types' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { CAPABILITY_RULES, @@ -427,7 +428,8 @@ const INVITATIONS_RULE = CAPABILITY_RULES['invitations.send'] /** permission-group-enforced: invitations.send — organization-scoped, so it resolves the default group rather than a workspace one */ export async function validateInvitationsAllowed( userId: string | undefined, - scope: string | { workspaceId?: string; organizationId?: string } = {} + scope: string | { workspaceId?: string; organizationId?: string } = {}, + executor?: DbOrTx ): Promise { if (isInvitationsDisabled) { logger.warn('Invitations blocked by feature flag') @@ -442,7 +444,9 @@ export async function validateInvitationsAllowed( typeof scope === 'string' ? { workspaceId: scope, organizationId: undefined } : scope if (workspaceId) { - const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) + const config = executor + ? await getUserPermissionConfig(userId, workspaceId, executor) + : await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group', { userId, workspaceId }) throw new InvitationsNotAllowedError() @@ -451,7 +455,9 @@ export async function validateInvitationsAllowed( } if (organizationId) { - const config = await getUserPermissionConfigForOrganization(organizationId) + const config = executor + ? await getUserPermissionConfigForOrganization(organizationId, executor) + : await getUserPermissionConfigForOrganization(organizationId) if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group (organization-wide)', { userId, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 44e18aa1ff4..2bd155871f3 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1783,6 +1783,7 @@ export async function revokeInvitationAsAdmin(input: { const revoked = await revokeInvitationWorkspaceGrantTx(tx, { invitationId: input.invitationId, workspaceId: input.workspaceId, + requireUnexpired: true, }) if (!revoked.revoked) return { success: false, kind: 'not-cancellable' } return { @@ -1851,9 +1852,12 @@ export async function revokeInvitationWorkspaceGrantTx( { invitationId, workspaceId, + requireUnexpired = false, }: { invitationId: string workspaceId: string + /** User revocation checks expiry; direct-grant cleanup may remove stale pending grants. */ + requireUnexpired?: boolean } ): Promise<{ revoked: boolean; invitationCancelled: boolean }> { const [pending] = await tx @@ -1869,7 +1873,13 @@ export async function revokeInvitationWorkspaceGrantTx( .where( and( eq(invitationWorkspaceGrant.invitationId, invitationId), - eq(invitationWorkspaceGrant.workspaceId, workspaceId) + eq(invitationWorkspaceGrant.workspaceId, workspaceId), + requireUnexpired + ? sql`exists (select 1 from ${invitation} + where ${invitation.id} = ${invitationId} + and ${invitation.status} = 'pending' + and ${invitation.expiresAt} > clock_timestamp())` + : undefined ) ) .returning({ id: invitationWorkspaceGrant.id }) diff --git a/apps/sim/lib/invitations/grant-revocation.test.ts b/apps/sim/lib/invitations/grant-revocation.test.ts index 2142863a02c..8c21a28f572 100644 --- a/apps/sim/lib/invitations/grant-revocation.test.ts +++ b/apps/sim/lib/invitations/grant-revocation.test.ts @@ -3,7 +3,13 @@ */ import { db } from '@sim/db' import { invitation, invitationWorkspaceGrant } from '@sim/db/schema' -import { auditMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { + auditMock, + dbChainMockFns, + hasMockCondition, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/audit', () => auditMock) @@ -53,4 +59,24 @@ describe('revokeInvitationWorkspaceGrantTx', () => { expect.objectContaining({ status: 'cancelled' }) ) }) + + it('checks database expiry in the grant deletion itself and leaves expired invitations untouched', async () => { + queueTableRows(invitation, [{ id: 'inv-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect( + revokeInvitationWorkspaceGrantTx(db, { + invitationId: 'inv-1', + workspaceId: 'ws-1', + requireUnexpired: true, + }) + ).resolves.toEqual({ revoked: false, invitationCancelled: false }) + const [predicate] = dbChainMockFns.where.mock.calls[1] + expect( + hasMockCondition( + predicate, + (node) => Array.isArray(node.strings) && node.strings.join('').includes('clock_timestamp()') + ) + ).toBe(true) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/invitations/mutation-manager.ts b/apps/sim/lib/invitations/mutation-manager.ts index 1858ff2b579..94ec149f89a 100644 --- a/apps/sim/lib/invitations/mutation-manager.ts +++ b/apps/sim/lib/invitations/mutation-manager.ts @@ -2,17 +2,9 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getOrganizationSubscription } from '@/lib/billing/core/billing' -import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' -import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - type InvitationWithGrants, - resolveInvitationAdmissionOrganizationId, - revokeInvitationAsAdmin, -} from '@/lib/invitations/core' +import { type InvitationWithGrants, revokeInvitationAsAdmin } from '@/lib/invitations/core' import { InvitationNotPendingError } from '@/lib/invitations/errors' import { prepareInvitationResend, @@ -20,9 +12,6 @@ import { sendInvitationEmail, } from '@/lib/invitations/send' import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' -import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' const logger = createLogger('InvitationMutationManager') @@ -34,44 +23,6 @@ export async function resendInvitationRecord(input: { const inv = input.invitation if (inv.status !== 'pending' || inv.expiresAt.getTime() <= Date.now()) throw new InvitationNotPendingError('resend') - /** permission-group-enforced: invitations.send — resend rechecks organization admission and every workspace grant. */ - const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) - if (admissionOrganizationId) - await validateInvitationsAllowed(input.actorUserId, { organizationId: admissionOrganizationId }) - for (const grant of inv.grants) { - await validateInvitationsAllowed(input.actorUserId, { workspaceId: grant.workspaceId }) - const details = await getWorkspaceWithOwner(grant.workspaceId) - if (!details) - throw new OrchestrationError( - 'conflict', - 'Invitation references a workspace that no longer exists' - ) - const policy = await getWorkspaceInvitePolicy(details) - if (!policy.allowed) - throw new WorkspaceInvitationError({ - status: 403, - message: policy.reason ?? 'Invites are no longer allowed on this workspace', - upgradeRequired: policy.upgradeRequired, - }) - } - if ( - isBillingEnabled && - inv.kind === 'organization' && - inv.grants.length === 0 && - inv.organizationId - ) { - const subscription = await getOrganizationSubscription(inv.organizationId) - if ( - !subscription || - !hasUsableSubscriptionStatus(subscription.status) || - (!isTeam(subscription.plan) && !isEnterprise(subscription.plan)) - ) - throw new WorkspaceInvitationError({ - status: 403, - message: 'Invites are no longer allowed on this organization', - upgradeRequired: true, - }) - } const [actor] = await db .select({ name: user.name, email: user.email }) .from(user) diff --git a/apps/sim/lib/invitations/resend-policy.test.ts b/apps/sim/lib/invitations/resend-policy.test.ts new file mode 100644 index 00000000000..bbfec670264 --- /dev/null +++ b/apps/sim/lib/invitations/resend-policy.test.ts @@ -0,0 +1,126 @@ +/** @vitest-environment node */ +import { db } from '@sim/db' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + workspace: vi.fn(), + organizationLock: vi.fn(), + billingLock: vi.fn(), + groupLock: vi.fn(), + authority: vi.fn(), + admission: vi.fn(), + capability: vi.fn(), + workspacePolicy: vi.fn(), + subscription: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mocks.workspace })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.organizationLock, +})) +vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ + acquireUserBillingIdentityLock: mocks.billingLock, +})) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) +vi.mock('@/lib/invitations/core', () => ({ + requireInvitationResendAuthority: mocks.authority, + resolveInvitationAdmissionOrganizationId: mocks.admission, +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateInvitationsAllowed: mocks.capability, +})) +vi.mock('@/lib/workspaces/policy', () => ({ + WORKSPACE_MODE: { ORGANIZATION: 'organization' }, + getWorkspaceInvitePolicy: mocks.workspacePolicy, +})) +vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mocks.subscription })) + +import type { InvitationWithGrants } from '@/lib/invitations/core' +import { lockInvitationResendPolicy } from '@/lib/invitations/resend-policy' + +const invitation: InvitationWithGrants = { + id: 'invite', + kind: 'organization', + organizationId: 'org', + membershipIntent: 'internal', + email: 'person@example.com', + inviterId: 'actor', + role: 'member', + status: 'pending', + token: 'token', + expiresAt: new Date('2099-01-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + organizationName: 'Organization', + inviterName: 'Admin', + inviterEmail: 'admin@example.com', + grants: [ + { id: 'grant', workspaceId: 'workspace', permission: 'read', workspaceName: 'Workspace' }, + ], +} + +beforeEach(() => { + vi.resetAllMocks() + setEnvFlags({ isBillingEnabled: true }) + mocks.workspace.mockResolvedValue({ + id: 'workspace', + organizationId: 'org', + workspaceMode: 'personal', + billedAccountUserId: 'billed-user', + }) + mocks.admission.mockResolvedValue('org') + mocks.workspacePolicy.mockResolvedValue({ allowed: true }) + mocks.subscription.mockResolvedValue({ status: 'active', plan: 'team' }) +}) +afterAll(resetEnvFlagsMock) + +describe('locked resend policy', () => { + it('locks parent contexts before authority rows and permission-group leaves, then reads policy on the same executor', async () => { + await lockInvitationResendPolicy(db, invitation, 'actor', 'org') + const order = [ + mocks.organizationLock, + mocks.billingLock, + mocks.authority, + mocks.groupLock, + mocks.capability, + ].map((mock) => mock.mock.invocationCallOrder[0]) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(mocks.authority).toHaveBeenCalledWith(db, invitation, 'actor', 'org') + expect(mocks.admission).toHaveBeenCalledWith(invitation, db) + expect(mocks.capability).toHaveBeenCalledWith('actor', { organizationId: 'org' }, db) + expect(mocks.capability).toHaveBeenCalledWith('actor', { workspaceId: 'workspace' }, db) + expect(mocks.workspacePolicy).toHaveBeenCalledWith( + await mocks.workspace.mock.results[0].value, + db + ) + }) + + it('observes a restriction committed while waiting for the policy lock', async () => { + const refusal = new Error('Invitations restricted') + mocks.groupLock.mockImplementation(async () => { + mocks.capability.mockRejectedValue(refusal) + }) + await expect(lockInvitationResendPolicy(db, invitation, 'actor')).rejects.toBe(refusal) + expect(mocks.workspacePolicy).not.toHaveBeenCalled() + }) + + it('refuses a workspace whose paid invitation policy has lapsed', async () => { + mocks.workspacePolicy.mockResolvedValue({ + allowed: false, + upgradeRequired: true, + reason: 'Plan required', + }) + await expect(lockInvitationResendPolicy(db, invitation, 'actor')).rejects.toMatchObject({ + status: 403, + upgradeRequired: true, + }) + }) + + it('rechecks grantless organization billing on the locked executor', async () => { + mocks.subscription.mockResolvedValue(null) + await expect( + lockInvitationResendPolicy(db, { ...invitation, grants: [] }, 'actor') + ).rejects.toMatchObject({ status: 403 }) + expect(mocks.subscription).toHaveBeenCalledWith('org', { executor: db, onError: 'throw' }) + }) +}) diff --git a/apps/sim/lib/invitations/resend-policy.ts b/apps/sim/lib/invitations/resend-policy.ts new file mode 100644 index 00000000000..4eeab9ad831 --- /dev/null +++ b/apps/sim/lib/invitations/resend-policy.ts @@ -0,0 +1,97 @@ +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { + type InvitationWithGrants, + requireInvitationResendAuthority, + resolveInvitationAdmissionOrganizationId, +} from '@/lib/invitations/core' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { getWorkspaceWithOwner, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy, WORKSPACE_MODE } from '@/lib/workspaces/policy' +import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' + +/** + * Revalidates resend policy on the mutation connection. Invitation/workspace locks + * precede organization and billing-identity locks; permission-group locks are leaves. + */ +export async function lockInvitationResendPolicy( + tx: DbOrTx, + invitation: InvitationWithGrants, + actorUserId: string, + assertedOrganizationId?: string +): Promise { + const workspaces: WorkspaceWithOwner[] = [] + for (const grant of invitation.grants) { + const workspace = await getWorkspaceWithOwner(grant.workspaceId, { executor: tx }) + if (!workspace) + throw new OrchestrationError( + 'conflict', + 'Invitation references a workspace that no longer exists' + ) + workspaces.push(workspace) + } + const organizationIds = [ + ...new Set([ + ...(invitation.organizationId ? [invitation.organizationId] : []), + ...workspaces.flatMap((workspace) => + workspace.organizationId ? [workspace.organizationId] : [] + ), + ]), + ].sort() + for (const organizationId of organizationIds) + await acquireOrganizationMutationLock(tx, organizationId) + const billedUserIds = [ + ...new Set( + workspaces + .filter((workspace) => workspace.workspaceMode !== WORKSPACE_MODE.ORGANIZATION) + .map((workspace) => workspace.billedAccountUserId) + ), + ].sort() + for (const userId of billedUserIds) await acquireUserBillingIdentityLock(tx, userId) + await requireInvitationResendAuthority(tx, invitation, actorUserId, assertedOrganizationId) + for (const organizationId of organizationIds) + await acquirePermissionGroupOrgLock(tx, organizationId) + + /** permission-group-enforced: invitations.send — fresh transaction reads bypass request-scoped config caches. */ + const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(invitation, tx) + if (admissionOrganizationId) + await validateInvitationsAllowed(actorUserId, { organizationId: admissionOrganizationId }, tx) + for (const workspace of workspaces) { + await validateInvitationsAllowed(actorUserId, { workspaceId: workspace.id }, tx) + const policy = await getWorkspaceInvitePolicy(workspace, tx) + if (!policy.allowed) + throw new WorkspaceInvitationError({ + status: 403, + message: policy.reason ?? 'Invites are no longer allowed on this workspace', + upgradeRequired: policy.upgradeRequired, + }) + } + if ( + isBillingEnabled && + invitation.kind === 'organization' && + !workspaces.length && + invitation.organizationId + ) { + const subscription = await getOrganizationSubscription(invitation.organizationId, { + executor: tx, + onError: 'throw', + }) + if ( + !subscription || + !hasUsableSubscriptionStatus(subscription.status) || + (!isTeam(subscription.plan) && !isEnterprise(subscription.plan)) + ) + throw new WorkspaceInvitationError({ + status: 403, + message: 'Invites are no longer allowed on this organization', + upgradeRequired: true, + }) + } +} diff --git a/apps/sim/lib/invitations/send-resend.test.ts b/apps/sim/lib/invitations/send-resend.test.ts index 52f0a431989..da5ded3362f 100644 --- a/apps/sim/lib/invitations/send-resend.test.ts +++ b/apps/sim/lib/invitations/send-resend.test.ts @@ -3,13 +3,14 @@ import { invitation } from '@sim/db/schema' import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ lock: vi.fn(), authority: vi.fn() })) +const mocks = vi.hoisted(() => ({ lock: vi.fn(), policy: vi.fn() })) vi.mock('@/lib/invitations/core', async (original) => ({ ...(await original()), lockInvitationForMutation: mocks.lock, - requireInvitationResendAuthority: mocks.authority, })) +vi.mock('@/lib/invitations/resend-policy', () => ({ lockInvitationResendPolicy: mocks.policy })) + import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { prepareInvitationResend, revertInvitationResend } from '@/lib/invitations/send' @@ -36,7 +37,7 @@ beforeEach(() => { dbChainMockFns.returning.mockResolvedValue([{ id: 'inv' }]) }) describe('resend preparation and compensation', () => { - it('rechecks authority under the invitation locks and conditionally updates the original pending revision', async () => { + it('rechecks policy under the invitation locks and conditionally updates the original pending revision', async () => { const prepared = await prepareInvitationResend(input) expect(prepared).toMatchObject({ invitationId: 'inv', @@ -51,20 +52,19 @@ describe('resend preparation and compensation', () => { expect(mocks.lock).toHaveBeenCalledWith(expect.anything(), 'inv', { lockCurrentGrantWorkspaces: true, }) - expect(mocks.authority).toHaveBeenCalledWith( + expect(mocks.policy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'inv' }), 'actor', 'org' ) - expect(mocks.authority.mock.invocationCallOrder[0]).toBeLessThan( + expect(mocks.policy.mock.invocationCallOrder[0]).toBeLessThan( dbChainMockFns.update.mock.invocationCallOrder[0] ) const [predicate] = dbChainMockFns.where.mock.calls[0] for (const [column, value] of [ [invitation.status, 'pending'], [invitation.token, 'original-token'], - [invitation.updatedAt, revision], [invitation.organizationId, 'org'], ]) expect( @@ -80,6 +80,37 @@ describe('resend preparation and compensation', () => { await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) }) + it('accepts a hydrated legacy revision without comparing a JavaScript Date to a microsecond SQL value', async () => { + const legacyRevision = new Date('2026-01-01T00:00:00.123456Z') + mocks.lock.mockResolvedValue({ + id: 'inv', + organizationId: 'org', + status: 'pending', + token: input.currentToken, + updatedAt: legacyRevision, + expiresAt: new Date('2099-01-01'), + }) + await prepareInvitationResend({ ...input, expectedUpdatedAt: legacyRevision }) + const [predicate] = dbChainMockFns.where.mock.calls[0] + expect( + hasMockCondition( + predicate, + (node) => node.type === 'eq' && node.left === invitation.updatedAt + ) + ).toBe(false) + }) + + it('rejects a changed hydrated revision before policy checks or writes', async () => { + mocks.lock.mockResolvedValue({ + organizationId: 'org', + token: input.currentToken, + updatedAt: new Date('2026-01-02'), + }) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.policy).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('rejects canonical organization changes without touching the token', async () => { mocks.lock.mockResolvedValue({ organizationId: 'different' }) await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'not_found' }) @@ -87,11 +118,18 @@ describe('resend preparation and compensation', () => { }) it('rejects demotion and expiration before delivery', async () => { - mocks.authority.mockRejectedValueOnce( + mocks.policy.mockRejectedValueOnce( new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') ) await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'forbidden' }) - mocks.lock.mockResolvedValue({ organizationId: 'org', expiresAt: new Date('2000-01-01') }) + mocks.lock.mockResolvedValue({ + id: 'inv', + organizationId: 'org', + token: input.currentToken, + updatedAt: revision, + status: 'pending', + expiresAt: new Date('2000-01-01'), + }) await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'conflict' }) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index e89d73642fd..1f8cc821802 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -23,13 +23,10 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbOrTx } from '@/lib/db/types' -import { - computeInvitationExpiry, - lockInvitationForMutation, - requireInvitationResendAuthority, -} from '@/lib/invitations/core' +import { computeInvitationExpiry, lockInvitationForMutation } from '@/lib/invitations/core' import { InvitationNotPendingError } from '@/lib/invitations/errors' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { lockInvitationResendPolicy } from '@/lib/invitations/resend-policy' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { getBrandConfig } from '@/ee/whitelabeling' @@ -723,12 +720,16 @@ export async function prepareInvitationResend(params: { current.organizationId !== params.expectedOrganizationId) ) throw new OrchestrationError('not_found', 'Invitation not found') - await requireInvitationResendAuthority( - tx, - current, - params.actorUserId, - params.expectedOrganizationId + /** Compare hydrated revisions while the row is locked; legacy timestamps retain sub-millisecond precision in SQL. */ + if ( + current.token !== params.currentToken || + current.updatedAt.getTime() !== params.expectedUpdatedAt.getTime() ) + throw new OrchestrationError( + 'conflict', + 'The invitation changed before it could be resent. Refresh before resending.' + ) + await lockInvitationResendPolicy(tx, current, params.actorUserId, params.expectedOrganizationId) if (current.status !== 'pending' || current.expiresAt.getTime() <= Date.now()) throw new InvitationNotPendingError('resend') @@ -743,7 +744,6 @@ export async function prepareInvitationResend(params: { eq(invitation.id, params.invitationId), eq(invitation.status, 'pending'), eq(invitation.token, params.currentToken), - eq(invitation.updatedAt, params.expectedUpdatedAt), sql`${invitation.expiresAt} > clock_timestamp()`, params.expectedOrganizationId === undefined ? undefined diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index cc3ec9ef0f1..2edc401aff0 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -217,7 +217,8 @@ export async function resolveWorkspaceGroup( async function resolveUserAccessControlContextForOrganization( userId: string, workspaceId: string, - organizationId: string | null + organizationId: string | null, + executor?: DbOrTx ): Promise { if (!organizationId) return inactiveUserAccessControlContext(null) @@ -228,12 +229,14 @@ async function resolveUserAccessControlContextForOrganization( * one may answer here; both would be indistinguishable from a genuine plan lapse and would lift * the whole regime. It throws on the first and keeps governing through the second. */ - const isEnterprise = await isOrganizationGovernanceActive(organizationId) + const isEnterprise = executor + ? await isOrganizationGovernanceActive(organizationId, executor) + : await isOrganizationGovernanceActive(organizationId) if (!isEnterprise) { return inactiveUserAccessControlContext(organizationId) } - const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) + const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId, executor ?? db) return { organizationId, entitled: true, @@ -274,17 +277,22 @@ export async function resolveVerifiedUserAccessControlContext( */ export async function getUserPermissionConfig( userId: string, - workspaceId: string + workspaceId: string, + executor?: DbOrTx ): Promise { if (!isHosted && !isAccessControlEnabled) { return mergeEnvAllowlist(null) } - const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + const workspace = await getWorkspaceWithOwner(workspaceId, { + includeArchived: true, + ...(executor ? { executor } : {}), + }) const context = await resolveUserAccessControlContextForOrganization( userId, workspaceId, - workspace?.organizationId ?? null + workspace?.organizationId ?? null, + executor ) return context.config } @@ -297,12 +305,13 @@ export async function getUserPermissionConfig( * covered by a workspace group. */ export async function getUserPermissionConfigForOrganization( - organizationId: string + organizationId: string, + executor?: DbOrTx ): Promise { - if (!(await isOrganizationPermissionRegimeActive(organizationId))) { + if (!(await isOrganizationPermissionRegimeActive(organizationId, executor))) { return mergeEnvAllowlist(null) } - return getEntitledOrganizationPermissionConfig(organizationId, db) + return getEntitledOrganizationPermissionConfig(organizationId, executor ?? db) } /** diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 42c2e336eb1..c456fb491d2 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -318,10 +318,11 @@ export function isOrganizationWorkspace( * keep their access — this policy only governs *new* invitations. */ export async function getWorkspaceInvitePolicy( - workspaceState: WorkspaceOwnershipState + workspaceState: WorkspaceOwnershipState, + executor: DbOrTx = db ): Promise { const billedPlanCategory = isBillingEnabled - ? await resolveBilledPlanCategory(workspaceState) + ? await resolveBilledPlanCategory(workspaceState, executor) : 'free' return evaluateWorkspaceInvitePolicy(workspaceState, { billedPlanCategory }) } @@ -394,15 +395,16 @@ function blockInvite(organizationId: string | null): WorkspaceInvitePolicy { } async function resolveBilledPlanCategory( - workspaceState: WorkspaceOwnershipState + workspaceState: WorkspaceOwnershipState, + executor: DbOrTx ): Promise { if ( workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION && workspaceState.organizationId ) { - return getInvitePlanCategoryForOrganization(workspaceState.organizationId) + return getInvitePlanCategoryForOrganization(workspaceState.organizationId, executor) } - return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId) + return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId, executor) } /** @@ -412,10 +414,11 @@ async function resolveBilledPlanCategory( * blocked consistently with accept-time provisioning. */ export async function getInvitePlanCategoryForOrganization( - organizationId: string + organizationId: string, + executor: DbOrTx = db ): Promise { try { - const orgSub = await getOrganizationSubscription(organizationId) + const orgSub = await getOrganizationSubscription(organizationId, { executor }) if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return 'free' return getPlanType(orgSub.plan) } catch (error) { From b394cd68aa11523b0eeac0841dbea410f6346a22 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 21 Sep 2026 11:21:54 -0700 Subject: [PATCH 7/7] fix(organizations): preserve member administration compatibility --- apps/docs/openapi-v2-resources.json | 8 ++--- .../api/contracts/v2/openapi/organizations.ts | 6 ++-- .../sim/lib/api/contracts/v2/organizations.ts | 2 +- .../lib/api/mcp/generated/v2-operations.ts | 6 ++-- .../membership-external-removal.test.ts | 25 ++++++++++++++++ .../lib/billing/organizations/membership.ts | 5 +++- .../lib/organizations/application/members.ts | 29 ++++++++----------- .../application/use-cases.test.ts | 20 +++++++++++-- .../lib/organizations/member-manager.test.ts | 17 +++++++++-- apps/sim/lib/organizations/member-manager.ts | 14 ++++++--- 10 files changed, 95 insertions(+), 37 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 14be34561fe..de991e52ce0 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -6376,7 +6376,7 @@ "post": { "operationId": "createOrganizationInvitation", "summary": "Create Organization Invitation", - "description": "Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. An unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "organizations.invitations.create", "x-oauth-scope": "api:write", "tags": ["Organizations"], @@ -6539,7 +6539,7 @@ "delete": { "operationId": "revokeOrganizationInvitation", "summary": "Revoke Organization Invitation", - "description": "Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Cancel an unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "invitations.revoke", "x-oauth-scope": "api:write", "tags": ["Organizations"], @@ -6620,7 +6620,7 @@ "post": { "operationId": "resendOrganizationInvitation", "summary": "Resend Organization Invitation", - "description": "Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Email an unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "invitations.resend", "x-oauth-scope": "api:write", "tags": ["Organizations"], @@ -14842,7 +14842,7 @@ }, "slug": { "type": "string", - "description": "Organization slug, or null when unset." + "description": "Organization slug." }, "logo": { "anyOf": [ diff --git a/apps/sim/lib/api/contracts/v2/openapi/organizations.ts b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts index b43dc97c302..56e15e7b9a0 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/organizations.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts @@ -316,7 +316,7 @@ export const organizationOpenApiRoutes = [ applicationOperation: organizationOperations.createInvitation, operationId: 'createOrganizationInvitation', summary: 'Create Organization Invitation', - description: `Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. ${WORKSPACE_API_KEY_DENIED}`, + description: `Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. An unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Organizations'], errors: RESOURCE_CONFLICT_ERRORS, success: { @@ -410,7 +410,7 @@ export const organizationOpenApiRoutes = [ applicationOperation: invitationOperations.resend, operationId: 'resendOrganizationInvitation', summary: 'Resend Organization Invitation', - description: `Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. ${WORKSPACE_API_KEY_DENIED}`, + description: `Email an unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Organizations'], errors: RESOURCE_CONFLICT_ERRORS, success: { @@ -462,7 +462,7 @@ export const organizationOpenApiRoutes = [ applicationOperation: invitationOperations.revoke, operationId: 'revokeOrganizationInvitation', summary: 'Revoke Organization Invitation', - description: `Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. ${WORKSPACE_API_KEY_DENIED}`, + description: `Cancel an unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Organizations'], errors: RESOURCE_CONFLICT_ERRORS, success: { diff --git a/apps/sim/lib/api/contracts/v2/organizations.ts b/apps/sim/lib/api/contracts/v2/organizations.ts index 6149b1ff26b..05cbe292d40 100644 --- a/apps/sim/lib/api/contracts/v2/organizations.ts +++ b/apps/sim/lib/api/contracts/v2/organizations.ts @@ -34,7 +34,7 @@ export const v2OrganizationSchema = z .object({ id: z.string().describe('Organization identifier.'), name: z.string().describe('Organization display name.'), - slug: z.string().describe('Organization slug, or null when unset.'), + slug: z.string().describe('Organization slug.'), logo: z.string().nullable().describe('Organization logo URL, or null when unset.'), role: organizationRoleSchema.describe('The acting user’s role in this organization.'), createdAt: v2TimestampSchema.describe('When the organization was created.'), diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 62f58e35f59..fef2ce65fb1 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -653,7 +653,7 @@ export const V2_MCP_OPERATIONS = { contract: v2CreateOrganizationInvitationContract, summary: 'Create Organization Invitation', description: - 'Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. A unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Email an invitation to join the organization as a member or administrator. Requires organization administrator access, invitations enabled, and an available seat on an eligible plan. This grants no workspace-specific permissions. An unexpired pending invitation for the email conflicts; use Resend Organization Invitation to send it again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/organizations/[organizationId]/invitations/route').then( @@ -2070,7 +2070,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ResendOrganizationInvitationContract, summary: 'Resend Organization Invitation', description: - 'Email a unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Email an unexpired pending invitation again, renew its expiry, and replace its previous acceptance link. Requires organization administrator access and current invitation eligibility. Retrying sends another email; inspect the invitation after a delivery failure before retrying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import( @@ -2157,7 +2157,7 @@ export const V2_MCP_OPERATIONS = { contract: v2RevokeOrganizationInvitationContract, summary: 'Revoke Organization Invitation', description: - 'Cancel a unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Cancel an unexpired pending invitation and all its workspace grants so it can no longer be accepted. Requires organization administrator access. This does not remove a person who already accepted; use Remove Organization Member for that. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route').then( diff --git a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts index 1550518a43a..2e7218a8047 100644 --- a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts +++ b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts @@ -4,6 +4,7 @@ import { credential, knowledgeBase, member, workspaceFiles } from '@sim/db/schema' import { dbChainMockFns, hasMockCondition, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { mockSetOrgMemberUsageLimit } = vi.hoisted(() => ({ mockSetOrgMemberUsageLimit: vi.fn(), @@ -102,11 +103,34 @@ describe('external organization access removal', () => { organizationId: 'org-1', userId: 'target', memberId: 'membership', + onError: 'throw', }) ).rejects.toBe(failure) } ) + it.each([ + Object.assign(new Error('retry transaction'), { code: '40001' }), + Object.assign(new Error('retry transaction'), { code: '40P01' }), + Object.assign(new Error('retry transaction'), { code: '55P03' }), + new OrchestrationError('conflict', 'The membership changed before removal'), + ])('preserves failure results for legacy compound callers on $code', async (failure) => { + queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) + dbChainMockFns.transaction.mockRejectedValueOnce(failure) + + await expect( + removeUserFromOrganization({ + organizationId: 'org-1', + userId: 'target', + memberId: 'membership', + }) + ).resolves.toMatchObject({ + success: false, + error: 'Failed to remove user from organization', + }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + it('rejects an actor demoted before the locked removal', async () => { queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) queueTableRows(member, [{ id: 'actor-membership', role: 'member' }]) @@ -116,6 +140,7 @@ describe('external organization access removal', () => { userId: 'target', memberId: 'membership', actorUserId: 'actor', + onError: 'throw', }) ).rejects.toMatchObject({ code: 'forbidden' }) expect(dbChainMockFns.delete).not.toHaveBeenCalled() diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index ecbc649a7bd..c28c416b8b2 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -488,6 +488,8 @@ export interface RemoveMemberParams { spareSessionId?: string /** Acting member whose management authority is rechecked under the mutation lock. */ actorUserId?: string + /** Legacy compound callers consume failure results; application use cases propagate errors. */ + onError?: 'return-failure' | 'throw' /** * Only remove the member when they hold no remaining permission on any of the * org's workspaces, evaluated atomically under the membership lock. Used by @@ -1288,6 +1290,7 @@ export async function removeUserFromOrganization( spareSessionToken, spareSessionId, actorUserId, + onError = 'return-failure', } = params const billingActions = { @@ -1529,10 +1532,10 @@ export async function removeUserFromOrganization( return { success: true, removed: true, billingActions } } catch (error) { - if (error instanceof OrchestrationError || isRetryableTransactionError(error)) throw error if (error instanceof WorkspaceBillingAccountRemovalError) { return { success: false, error: error.message, billingActions } } + if (onError === 'throw') throw error logger.error('Failed to remove user from organization', { userId, diff --git a/apps/sim/lib/organizations/application/members.ts b/apps/sim/lib/organizations/application/members.ts index 6547956581a..2817d69a72c 100644 --- a/apps/sim/lib/organizations/application/members.ts +++ b/apps/sim/lib/organizations/application/members.ts @@ -20,23 +20,18 @@ export const updateOrganizationMember = defineAuthorizedOrganizationUseCase({ input: { organizationId: string; userId: string; role: 'member' | 'admin' | 'owner' } context: { userId: string } }) => updateOrganizationMemberRecord({ ...input, actorUserId: context.userId }), - projectAudit: ({ input, result }) => - result.changed - ? [ - { - action: AuditAction.ORG_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: input.organizationId, - description: `Changed role for member ${input.userId} to ${input.role}`, - metadata: { - targetUserId: input.userId, - targetEmail: result.member.userEmail, - targetName: result.member.userName, - changes: [{ field: 'role', from: result.previousRole, to: input.role }], - }, - }, - ] - : [], + projectAudit: ({ input, result }) => ({ + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: input.organizationId, + description: `Changed role for member ${input.userId} to ${input.role}`, + metadata: { + targetUserId: input.userId, + targetEmail: result.member.userEmail, + targetName: result.member.userName, + changes: [{ field: 'role', from: result.previousRole, to: input.role }], + }, + }), }) export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({ diff --git a/apps/sim/lib/organizations/application/use-cases.test.ts b/apps/sim/lib/organizations/application/use-cases.test.ts index 739d83867f7..356453e0fe7 100644 --- a/apps/sim/lib/organizations/application/use-cases.test.ts +++ b/apps/sim/lib/organizations/application/use-cases.test.ts @@ -161,10 +161,26 @@ describe('organization application operations', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) - it('does not audit an unchanged role or failed write', async () => { + it('preserves audit on a successful same-role request', async () => { queueTableRows(member, [{ role: 'admin' }]) - mocks.update.mockResolvedValueOnce({ member: target, changed: false }) + mocks.update.mockResolvedValueOnce({ + member: { ...target, role: 'admin' }, + previousRole: 'admin', + changed: false, + }) await updateOrganizationMember.execute({ principal: session, input: roleInput }) + expect(recordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actorId: 'actor', + resourceId: 'org', + metadata: expect.objectContaining({ + changes: [{ field: 'role', from: 'admin', to: 'admin' }], + }), + }) + ) + }) + + it('does not audit a failed role write', async () => { queueTableRows(member, [{ role: 'admin' }]) mocks.update.mockRejectedValueOnce(new Error('database unavailable')) await expect( diff --git a/apps/sim/lib/organizations/member-manager.test.ts b/apps/sim/lib/organizations/member-manager.test.ts index 6baf9e1c5ef..f073507180e 100644 --- a/apps/sim/lib/organizations/member-manager.test.ts +++ b/apps/sim/lib/organizations/member-manager.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment node */ -import { member } from '@sim/db/schema' +import { member, user } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -96,8 +96,21 @@ describe('organization member managers', () => { seatReduction: { changed: false }, }) expect(mocks.remove).toHaveBeenCalledWith( - expect.objectContaining({ actorUserId: 'actor', memberId: 'membership' }) + expect.objectContaining({ actorUserId: 'actor', memberId: 'membership', onError: 'throw' }) ) expect(mocks.scim).not.toHaveBeenCalled() }) + + it('reports membership added before external-removal preflight as a conflict', async () => { + queueTableRows(member, []) + queueTableRows(user, [{ id: 'target', name: 'Person', email: 'person@example.com' }]) + mocks.external.mockResolvedValue({ success: false, error: 'User is an organization member' }) + + await expect(removeOrganizationMemberRecord(input)).rejects.toMatchObject({ + code: 'conflict', + message: 'User is an organization member', + }) + expect(mocks.remove).not.toHaveBeenCalled() + expect(mocks.seats).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/organizations/member-manager.ts b/apps/sim/lib/organizations/member-manager.ts index 54991c25a17..7673bc22a48 100644 --- a/apps/sim/lib/organizations/member-manager.ts +++ b/apps/sim/lib/organizations/member-manager.ts @@ -72,9 +72,11 @@ export async function removeOrganizationMemberRecord(input: { const code = message === 'External workspace member not found' ? 'not_found' - : message === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR - ? 'validation' - : 'internal' + : message === 'User is an organization member' + ? 'conflict' + : message === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR + ? 'validation' + : 'internal' throw new OrchestrationError(code, message) } return { @@ -84,7 +86,11 @@ export async function removeOrganizationMemberRecord(input: { seatReduction: null, } } - const removal = await removeUserFromOrganization({ ...input, memberId: target.id }) + const removal = await removeUserFromOrganization({ + ...input, + memberId: target.id, + onError: 'throw', + }) if (!removal.success) { const message = removal.error || 'Failed to remove user from organization' const code =