From 666a9714bff8d0364715cc6ffcbe2412b6d55aa0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 21 Sep 2026 12:52:50 -0700 Subject: [PATCH 1/3] feat(organizations): expose organization and permission group administration (#8102) * feat(permission-groups): expose administration through API CLI and MCP * fix(permission-groups): preserve bulk membership behavior and locked entitlement checks * feat(organizations): align administration across API CLI and MCP * fix(docs): include organization endpoints in OpenAPI coverage * fix(organizations): harden invitation delivery and mutation boundaries * fix(invitations): revalidate policy and expiry under mutation locks * fix(organizations): preserve member administration compatibility --- .../docs/content/docs/api-reference/meta.json | 2 + apps/docs/content/docs/cli/commands.mdx | 2 + apps/docs/content/docs/cli/meta.json | 2 + apps/docs/content/docs/cli/organizations.mdx | 278 + .../content/docs/cli/permission-groups.mdx | 240 + apps/docs/content/docs/cli/reference.mdx | 510 +- apps/docs/content/docs/cli/tables.mdx | 4 +- apps/docs/lib/openapi-download.test.ts | 4 +- apps/docs/openapi-v2-resources.json | 14826 ++++++++++------ .../api/invitations/[id]/resend/route.test.ts | 184 +- .../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 +- .../[groupId]/members/bulk/route.ts | 229 +- .../[groupId]/members/route.ts | 423 +- .../permission-groups/[groupId]/route.test.ts | 53 +- .../[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 - apps/sim/app/api/organizations/[id]/route.ts | 148 +- .../organizations/[id]/workspaces/route.ts | 61 +- .../[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 + .../[groupId]/members/[userId]/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 + .../organizations/[organizationId]/route.ts | 17 + .../[organizationId]/workspaces/route.ts | 39 + .../app/api/v2/organizations/route.test.ts | 183 + apps/sim/app/api/v2/organizations/route.ts | 35 + .../api/workspaces/invitations/batch/route.ts | 6 +- .../utils/permission-check.test.ts | 39 +- .../access-control/utils/permission-check.ts | 12 +- apps/sim/lib/api/contracts/organization.ts | 36 + .../lib/api/contracts/permission-groups.ts | 81 +- .../v2/__tests__/list-pagination.test.ts | 31 + .../v2/__tests__/permission-groups.test.ts | 73 + .../api/contracts/v2/openapi/organizations.ts | 490 + .../contracts/v2/openapi/permission-groups.ts | 358 + .../lib/api/contracts/v2/openapi/resources.ts | 16 +- .../sim/lib/api/contracts/v2/organizations.ts | 288 + .../lib/api/contracts/v2/permission-groups.ts | 311 + .../lib/api/mcp/generated/v2-operations.ts | 239 + .../lib/api/server/organization-presenters.ts | 60 + .../api/server/permission-group-presenters.ts | 12 + .../lib/api/server/routes/organizations.ts | 72 + .../api/server/routes/permission-groups.ts | 30 + .../membership-external-removal.test.ts | 70 + .../lib/billing/organizations/membership.ts | 31 +- .../lib/copilot/generated/docs-manifest.ts | 2 + .../authorized-organization-use-case.ts | 77 + .../application/organization-authorization.ts | 14 +- .../application/authorize-mutation.ts | 104 + .../invitations/application/mutations.test.ts | 205 + .../lib/invitations/application/mutations.ts | 122 + .../lib/invitations/application/operations.ts | 50 + .../application/send-invitation-batch.test.ts | 11 +- .../application/send-invitation-batch.ts | 27 +- apps/sim/lib/invitations/core.test.ts | 30 + apps/sim/lib/invitations/core.ts | 60 +- apps/sim/lib/invitations/errors.ts | 8 + .../lib/invitations/grant-revocation.test.ts | 28 +- apps/sim/lib/invitations/mutation-manager.ts | 108 + .../organization-invitations.test.ts | 5 +- .../invitations/organization-invitations.ts | 38 +- .../sim/lib/invitations/resend-policy.test.ts | 126 + apps/sim/lib/invitations/resend-policy.ts | 97 + apps/sim/lib/invitations/send-resend.test.ts | 173 + apps/sim/lib/invitations/send.test.ts | 35 +- apps/sim/lib/invitations/send.ts | 144 +- .../organizations/application/invitations.ts | 58 + .../lib/organizations/application/members.ts | 81 + .../organizations/application/operations.ts | 91 + .../lib/organizations/application/reads.ts | 164 + .../application/use-cases.test.ts | 344 + .../lib/organizations/member-manager.test.ts | 116 + apps/sim/lib/organizations/member-manager.ts | 120 + 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 | 81 + .../application/operations.ts | 57 + .../application/use-cases.test.ts | 206 + .../application/use-cases.ts | 203 + 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 | 173 + .../lib/permission-groups/group-manager.ts | 242 + apps/sim/lib/permission-groups/list.ts | 167 + .../permission-groups/member-manager.test.ts | 222 + .../lib/permission-groups/member-manager.ts | 214 + .../lib/permission-groups/mutation.test.ts | 53 + apps/sim/lib/permission-groups/mutation.ts | 23 + apps/sim/lib/permission-groups/repository.ts | 90 + .../lib/permission-groups/resolve.server.ts | 27 +- apps/sim/lib/workspaces/policy.ts | 17 +- packages/sim-cli/src/contract/commands.ts | 139 + packages/sim-cli/src/generated/v2-api.ts | 1257 +- packages/sim-cli/src/http/client.test.ts | 7 + packages/sim-cli/src/runtime/build.test.ts | 184 + packages/sim-cli/src/runtime/build.ts | 1 + packages/sim-cli/src/runtime/options.ts | 10 + scripts/openapi/documents.test.ts | 4 +- 115 files changed, 20522 insertions(+), 8157 deletions(-) create mode 100644 apps/docs/content/docs/cli/organizations.mdx 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]/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 create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/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]/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/__tests__/permission-groups.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/organizations.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts create mode 100644 apps/sim/lib/api/contracts/v2/organizations.ts create mode 100644 apps/sim/lib/api/contracts/v2/permission-groups.ts create mode 100644 apps/sim/lib/api/server/organization-presenters.ts create mode 100644 apps/sim/lib/api/server/permission-group-presenters.ts create mode 100644 apps/sim/lib/api/server/routes/organizations.ts create mode 100644 apps/sim/lib/api/server/routes/permission-groups.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/errors.ts create mode 100644 apps/sim/lib/invitations/mutation-manager.ts create mode 100644 apps/sim/lib/invitations/resend-policy.test.ts create mode 100644 apps/sim/lib/invitations/resend-policy.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 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/mutation.test.ts create mode 100644 apps/sim/lib/permission-groups/mutation.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..bd9a0920315 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -17,6 +17,8 @@ "(generated)/files", "(generated)/knowledge-bases", "(generated)/workspaces", + "(generated)/organizations", + "(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..e63345e7a0f 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -44,6 +44,8 @@ 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 | | [`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..0ba739e7a34 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -27,6 +27,8 @@ "logs", "mcp-servers", "meta", + "organizations", + "permission-groups", "sandboxes", "secrets", "selectors", 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 new file mode 100644 index 00000000000..95a5cecb700 --- /dev/null +++ b/apps/docs/content/docs/cli/permission-groups.mdx @@ -0,0 +1,240 @@ +--- +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 identifier. | +| `--group ` | Yes | Permission group identifier. | +| `--user ` | 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 identifier. | +| `--group ` | Yes | Permission group identifier. | +| `--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. | + + + +## 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 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`. | +| `--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 | +| --- | --- | --- | +| `userId` | Yes | User identifier of the member to remove. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--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 identifier. | +| `--name ` | Yes | Group name, unique within the organization. | +| `--description ` | No | Optional group description. | +| `--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). | + + + +## 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 identifier. | +| `-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 identifier. | + + + +## 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 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`. | +| `--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 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). | +| `--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 483d1677144..7ece46c6d2d 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2996,6 +2996,512 @@ 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`. + +### 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 identifier. | +| `--group ` | Yes | Permission group identifier. | +| `--user ` | 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 identifier. | +| `--group ` | Yes | Permission group identifier. | +| `--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. | + + + +### 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 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`. | +| `--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 | +| --- | --- | --- | +| `userId` | Yes | User identifier of the member to remove. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--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 identifier. | +| `--name ` | Yes | Group name, unique within the organization. | +| `--description ` | No | Optional group description. | +| `--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). | + + + +### 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 identifier. | +| `-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 identifier. | + + + +### 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 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`. | +| `--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 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). | +| `--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). | + + + ## sim sandboxes Also spelled `sim sandbox`. @@ -4551,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/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 5fccfe6ce59..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(157) + expect(Object.keys(paths)).toHaveLength(170) expect(tags.map((tag) => tag.name)).toEqual([ 'Workspace Sync', 'Workflows', @@ -44,6 +44,8 @@ describe('OpenAPI download', () => { 'Tables', 'Knowledge Bases', 'Billing', + 'Organizations', + 'Permission Groups', 'Meta', 'Workspaces', 'MCP Servers', diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 533dcf0f5f3..de991e52ce0 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,14 @@ } ], "tags": [ + { + "name": "Organizations", + "description": "Discover organizations and manage their members and invitations." + }, + { + "name": "Permission Groups", + "description": "Manage organization permission groups, their restrictions, and membership." + }, { "name": "Meta", "description": "Discover what the calling API credential can reach." @@ -4739,2767 +4747,5979 @@ } } } - } - }, - "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" + "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 + } + }, + { + "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 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": "CONFLICT", - "message": "The request conflicts with the current state of the resource" + "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" } } }, - "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": { + "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": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "type": "string", + "minLength": 1, + "description": "Organization that owns the permission groups." } } - } - }, - "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" + ], + "requestBody": { + "required": true, + "description": "Create Permission Group inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePermissionGroupRequest" } } } - } - }, - "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" + "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" + "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": "Get 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": "INTERNAL_ERROR", - "message": "Internal server error" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPermissionGroupResponse" + } } } + }, + "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" } } }, - "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" + "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": [ + { + "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." + } + } + ], + "requestBody": { + "required": true, + "description": "Update Permission Group inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePermissionGroupRequest" + } + } } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "responses": { + "200": { + "description": "Update 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": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePermissionGroupResponse" + } } } + }, + "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" } } - } - }, - "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" + "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." + } + } ], - "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": "Delete 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/DeletePermissionGroupResponse" + } + } } - } - ] - }, - "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}/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": [ { - "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." + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." + } }, - "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.", + "schema": { + "default": "assignedAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["assignedAt", "userId"] + } }, - "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 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 + } }, - "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 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 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/ListPermissionGroupMembersResponse" + } } - ], - "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" + } }, - "transport": { - "default": "streamable-http", - "description": "Transport used to communicate with the server.", - "type": "string", - "enum": ["streamable-http"] + "400": { + "$ref": "#/components/responses/BadRequest" }, - "authType": { - "description": "Authentication method used by the server.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "url": { - "description": "Server endpoint URL.", - "type": "string" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "timeout": { - "description": "Per-request timeout in milliseconds.", - "type": "number" + "404": { + "$ref": "#/components/responses/NotFound" }, - "retries": { - "description": "Number of retries attempted per request.", - "type": "number" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." + "500": { + "$ref": "#/components/responses/InternalError" }, - "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" + } + } + }, + "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, + "description": "Organization that owns the permission groups." + } }, - "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": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "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" }, - { - "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/AddPermissionGroupMemberResponse" + } + } + } }, - "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": { + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/permission-groups/{groupId}/members/{userId}": { + "delete": { + "operationId": "removePermissionGroupMember", + "summary": "Remove Permission Group Member", + "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"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { "type": "string", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." + "minLength": 1, + "description": "Organization that owns the permission groups." + } }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + { + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User identifier of the member to remove.", + "schema": { + "type": "string", + "minLength": 1, + "description": "User identifier of the member to remove." + } } - }, - "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": "Remove Permission Group 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/RemovePermissionGroupMemberResponse" + } } - ], - "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"] + "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": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "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 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"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the permission groups.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "description": "Organization that owns the permission groups." } }, - "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", "url"], - "additionalProperties": false, - "title": "Create MCP server request", - "description": "Configuration for a new MCP server.", - "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Docs server", - "url": "https://mcp.example.com/sse", - "authType": "headers", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" + "name": "groupId", + "in": "path", + "required": true, + "description": "Permission group identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Permission group identifier." } } - ] - }, - "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 + ], + "requestBody": { + "required": true, + "description": "Bulk Add Permission Group Members inputs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAddPermissionGroupMembersRequest" + } } } - ] - }, - "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": { + "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" + } + } } - } - ] - }, - "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": { + "get": { + "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": ["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": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the organization name.", + "schema": { + "description": "Case-insensitive substring match against the organization name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "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": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt"] } - } - ] - }, - "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": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "inputSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "object", - "description": "JSON Schema type of the argument object. MCP requires `object`." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum organizations to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 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 + } + }, + { + "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 Organizations 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/ListOrganizationsResponse" + } + } + } }, - "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." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + } + } + }, + "/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": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + } + ], + "responses": { + "200": { + "description": "Get Organization 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/GetOrganizationResponse" + } } - ], - "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." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "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" + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create skill response", - "description": "The created skill including its content.", - "examples": [ + } + } + }, + "/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": [ { - "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": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." } - } - ] - }, - "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": [ + "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 + } + }, { - "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": "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"] } - } - ] - }, - "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." + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - ] - }, - "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." + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "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 + "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 } } - ] - }, - "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" + ], + "responses": { + "200": { + "description": "List Organization Workspaces result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" - } - ], - "description": "Display name of the skill editor." - }, - "image": { - "anyOf": [ - { - "type": "string" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - { - "type": "null" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "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 + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationWorkspacesResponse" + } } - ], - "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." + "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" } - }, - "required": ["workspaceId", "email"], - "additionalProperties": false, - "title": "Grant skill editor request", - "description": "Workspace scope and email of the member to grant.", - "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": [ { - "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." + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } }, - "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 + "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 } - } - ] - }, - "V2CustomTool": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique custom tool identifier." }, - "title": { - "type": "string", - "description": "Display title, unique within the workspace." + { + "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"] + } }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + { + "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 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 + } + }, + { + "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 Members result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "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." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationMembersResponse" + } + } + } }, - "code": { - "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "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." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "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." + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", - "examples": [ + } + } + }, + "/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": [ { - "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 + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, + { + "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." + } } - ] - }, - "CreateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + ], + "requestBody": { + "required": true, + "description": "Update Organization Member input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationMemberBody" + } + } } }, - "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"] - } - } + "responses": { + "200": { + "description": "Update Organization Member result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "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" + } + } } + }, + "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" } - ] + } }, - "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." + "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." + } }, - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "function", - "description": "Function declaration discriminator." + { + "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" }, - "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." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveOrganizationMemberResponse" + } + } + } }, - "code": { - "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "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" } - }, - "required": ["workspaceId", "title", "schema", "code"], - "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", - "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": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, + { + "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 + } + }, + { + "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"] + } + }, + { + "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"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum 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 + } + }, + { + "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" } }, - "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"] - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationInvitationsResponse" } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - ] + } }, - "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": [ + "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. 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"], + "parameters": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Create Organization Invitation input.", + "content": { + "application/json": { "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" + "$ref": "#/components/schemas/CreateOrganizationInvitationBody" + } } } - ] - }, - "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." + }, + "responses": { + "201": { + "description": "Create Organization Invitation result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "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." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationInvitationResponse" + } + } } }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + "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" } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", - "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", - "code": "return { ok: false }" - } - ] - }, - "V2CustomToolDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted custom tool." + "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 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 + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." } } - ] - }, - "V2Sandbox": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique sandbox identifier." + ], + "responses": { + "200": { + "description": "Get 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/GetOrganizationInvitationResponse" + } + } + } }, - "name": { - "type": "string", - "description": "Display name, unique within the workspace." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "language": { - "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "dependencies": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Package specifiers installed into the sandbox, one per entry." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "cliTools": { - "type": "array", - "items": { + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "revokeOrganizationInvitation", + "summary": "Revoke Organization Invitation", + "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"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { "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." + "minLength": 1, + "description": "Organization identifier." + } }, - "buildStatus": { - "anyOf": [ - { - "type": "string", - "enum": ["pending", "building", "ready", "failed"] + { + "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" - } - ], - "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." - }, - "errorCode": { - "anyOf": [ - { - "type": "string" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - { - "type": "null" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Classified build failure code, or `null`." - }, - "errorMessage": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeOrganizationInvitationResponse" + } } - ], - "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`." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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`." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "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." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "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." + "404": { + "$ref": "#/components/responses/NotFound" }, - "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." + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List sandboxes response", - "description": "Sandboxes defined in the workspace.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/invitations/{invitationId}/resend": { + "post": { + "operationId": "resendOrganizationInvitation", + "summary": "Resend Organization Invitation", + "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"], + "parameters": [ { - "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": [ + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, { - "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" + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." } } - ] - }, - "CreateSandboxRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the sandbox." + ], + "requestBody": { + "required": false, + "description": "Resend Organization Invitation input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendOrganizationInvitationBody" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current state of the resource" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "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." + }, + "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": ["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." + } + } + ] + }, + "V2Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "name": { + "type": "string", + "description": "Workspace display name." + }, + "color": { + "type": "string", + "description": "Workspace color as a hexadecimal color value." + }, + "logoUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workspace logo URL, or null when none is configured." + }, + "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 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": ["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." + }, + "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" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get workspace response", + "description": "Public metadata for one workspace.", + "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" + } + } + ] + }, + "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" + } + ], + "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" + }, + { + "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 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" + } + ], + "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" + }, + "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" + }, + { + "type": "null" + } + ] + }, + "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", + "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))$" + }, + "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))$" + }, + "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", + "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" + }, + { + "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 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 + } + ], + "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." + }, + "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", + "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", + "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", "url"], + "additionalProperties": false, + "title": "Create MCP server request", + "description": "Configuration for a new MCP server.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + ] + }, + "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 + } + } + ] + }, + "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 + } + } + ] + }, + "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 + }, + "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", + "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"], + "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." + }, + "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 + } + } + ] + }, + "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`." + }, + "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." + }, + "serverId": { + "type": "string", + "description": "Identifier of the MCP server exposing the tool." + }, + "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." + }, + "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`." + }, + "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, + "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": 64, - "description": "Display name, unique within the workspace; 1 to 64 characters." + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." }, - "language": { + "scope": { "type": "string", - "enum": ["javascript", "python"], - "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." + "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." }, - "dependencies": { - "default": [], - "description": "Package specifiers installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "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." }, - "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" - ] - } + "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." }, - "systemPackages": { - "default": [], - "description": "Debian packages installed into the sandbox, one per entry.", - "maxItems": 1000, + "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": { - "type": "string", - "maxLength": 2000 - } + "$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": ["workspaceId", "name", "language"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Create sandbox request", - "description": "Name, language, and dependency set of a new sandbox.", + "title": "List secrets response", + "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "data-tools", - "language": "python", - "dependencies": ["pandas==2.2.2", "requests"], - "systemPackages": ["graphviz"] + "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 } ] }, - "GetSandboxResponse": { + "V2Secret": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "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": ["data"], + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], "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" - } - } - ] + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." }, - "UpdateSandboxResponse": { + "SetSecretResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + "$ref": "#/components/schemas/V2Secret" } }, "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": "Set secret response", + "description": "Metadata for the created or replaced secret without its value.", "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": "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" } } ] }, - "UpdateSandboxRequest": { + "SetSecretRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the sandbox." + "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." }, - "name": { - "description": "New display name, unique within the workspace; 1 to 64 characters.", + "scope": { "type": "string", - "minLength": 1, - "maxLength": 64 + "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." }, - "language": { - "description": "Replacement dependency ecosystem. The whole spec is revalidated against it, so a Python dependency list does not survive a switch to JavaScript.", + "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", - "enum": ["javascript", "python"] - }, - "dependencies": { - "description": "Replacement package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "minLength": 1, + "maxLength": 65536 }, - "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" - ] - } + "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" + } + ] }, - "systemPackages": { - "description": "Replacement Debian package list; replaces the whole list.", - "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" } }, - "required": ["workspaceId"], + "required": ["workspaceId", "scope"], "additionalProperties": false, - "title": "Update sandbox request", - "description": "Sandbox fields to change; at least one editable field is required.", + "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", - "dependencies": ["pandas==2.2.2", "requests", "pyarrow"] + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false } ] }, - "V2SandboxDeleteData": { + "V2SecretDeleteData": { "type": "object", "properties": { - "id": { + "name": { "type": "string", - "description": "Identifier of the deleted sandbox." + "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 sandbox was deleted." + "description": "Whether the secret was deleted." } }, - "required": ["id", "deleted"], + "required": ["name", "scope", "deleted"], "additionalProperties": false, - "title": "Delete sandbox data", - "description": "Sandbox deletion acknowledgement." + "title": "Delete secret data", + "description": "Secret deletion acknowledgement without the stored value." }, - "DeleteSandboxResponse": { + "DeleteSecretResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SandboxDeleteData" + "$ref": "#/components/schemas/V2SecretDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete sandbox response", - "description": "Acknowledgement that the sandbox was deleted.", + "title": "Delete secret response", + "description": "Acknowledgement that the secret was deleted.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", + "name": "STRIPE_API_KEY", + "scope": "workspace", "deleted": true } } ] }, - "V2Credential": { + "V2Meta": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique credential identifier." - }, - "type": { - "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "v2Enabled": { + "type": "boolean", + "description": "Whether this API version is available. This is true when the endpoint is served." }, - "displayName": { + "keyType": { "type": "string", - "description": "Credential display name." + "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." }, - "description": { + "expiresAt": { "anyOf": [ { - "type": "string" + "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": "Optional credential description." + "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." }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." }, - "accountId": { + "description": { "anyOf": [ { "type": "string" @@ -7508,53 +10728,63 @@ "type": "null" } ], - "description": "Linked account identifier for OAuth credentials." + "description": "Optional server description, or null when unset." }, - "hasServiceAccountKey": { + "isPublic": { "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "description": "Whether the server answers MCP clients without a Sim API key." }, - "role": { + "mcpServerUrl": { "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "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", - "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": "ISO 8601 timestamp when the server was created.", + "format": "date-time" }, "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": "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", - "type", - "displayName", + "name", "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", + "isPublic", + "mcpServerUrl", "createdAt", - "updatedAt" + "updatedAt", + "toolCount", + "toolNames" ], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "Workflow MCP server list item", + "description": "A published MCP server together with the tool names it exposes." }, - "ListCredentialsResponse": { + "ListWorkflowMcpServersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/WorkflowMcpServerListItem" }, "description": "Items in the current page." }, @@ -7568,827 +10798,424 @@ } ], "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"], + "required": ["data", "nextCursor", "toolNamesTruncated"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "List workflow MCP servers response", + "description": "A cursor-paginated page of published MCP servers.", "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, - "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." + "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"] } - }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions", - "fields" ], - "additionalProperties": false + "nextCursor": null, + "toolNamesTruncated": false + } + ] + }, + "WorkflowMcpServer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." }, - { - "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." + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "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." + { + "type": "null" } - }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "providerId", - "docsUrl", - "requiresClientGeneratedCredentialId", - "fields" ], - "additionalProperties": false + "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" ], - "title": "Credential Provider", - "description": "An OAuth or service-account connection method available to a workspace." + "additionalProperties": false, + "title": "Workflow MCP server", + "description": "A workspace-published MCP server exposing deployed workflows as tools." }, - "ListCredentialProvidersResponse": { + "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." + }, + "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": { - "$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." + "type": "string", + "minLength": 1 + } } }, - "required": ["data", "nextCursor"], + "required": ["workspaceId", "name"], "additionalProperties": false, - "title": "List credential providers response", - "description": "OAuth and service-account connection methods.", + "title": "Create workflow MCP server request", + "description": "A new workspace-published MCP server and the workflows it exposes.", "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 + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "name": "Support agents", + "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } ] }, - "CreateServiceAccountCredentialResponse": { + "GetWorkflowMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/WorkflowMcpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create service-account credential response", - "description": "Verified credential metadata without secret material.", + "title": "Get workflow MCP server response", + "description": "A single published 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" + "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" } } ] }, - "CreateServiceAccountCredentialRequest": { + "WorkflowMcpToolListItem": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." + "description": "Unique tool identifier." }, - "type": { + "serverId": { "type": "string", - "const": "service_account", - "description": "Service-account credential discriminator." + "description": "Server that publishes this tool." }, - "providerId": { + "workflowId": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID returned by provider discovery." + "description": "Workflow this tool executes." }, - "displayName": { - "description": "Optional name; providers may derive one from the verified account identity.", + "toolName": { "type": "string", - "minLength": 1, - "maxLength": 255 + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." }, - "description": { - "description": "Optional credential description.", + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { "type": "string", - "maxLength": 500 + "description": "Endpoint an MCP client connects to." }, - "id": { - "description": "Required only when provider discovery requests a client-generated ID.", + "apiEndpoint": { "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)$" + "description": "Sim execution endpoint this tool calls through." }, - "credentials": { + "createdAt": { "type": "string", - "minLength": 1, - "maxLength": 131072, - "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", - "writeOnly": true + "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": ["workspaceId", "type", "providerId", "credentials"], + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "createdAt", + "updatedAt" + ], "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": "Workflow MCP tool list item", + "description": "A tool a server publishes, as returned by a read." }, - "V2CredentialConnectionAuthorization": { + "ListWorkflowMcpToolsResponse": { "type": "object", "properties": { - "authorizationUrl": { - "type": "string", - "format": "uri", - "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpToolListItem" + }, + "description": "Items in the current page." }, - "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." + "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": ["authorizationUrl", "expiresAt"], + "required": ["data", "nextCursor", "truncated"], "additionalProperties": false, - "title": "Credential Connection Authorization", - "description": "A short-lived browser entrypoint for an OAuth connection flow." + "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 + } + ] }, - "CreateCredentialConnectionResponse": { + "UpdateWorkflowMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + "$ref": "#/components/schemas/WorkflowMcpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create credential connection response", - "description": "Short-lived Sim browser entrypoint and its expiry.", + "title": "Update workflow MCP server response", + "description": "The updated MCP server.", "examples": [ { "data": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", - "expiresAt": "2026-06-20T14:17:11.000Z" + "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" } } ] }, - "CreateCredentialConnectionBody": { - "anyOf": [ - { + "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": "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": "string", + "maxLength": 2000 }, { - "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": "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": [ { - "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 + "isPublic": true } - ], - "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": { + "DeleteWorkflowMcpServerResult": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Disconnected credential identifier." + "description": "Identifier of the unpublished server." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the credential was disconnected." + "description": "Whether the server was unpublished." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete credential data", - "description": "Credential disconnection acknowledgement." + "title": "Delete workflow MCP server result", + "description": "Unpublish acknowledgement." }, - "DeleteCredentialResponse": { + "DeleteWorkflowMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialDeleteData" + "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Disconnect credential response", - "description": "Acknowledgement that the credential was disconnected.", + "title": "Delete workflow MCP server response", + "description": "Acknowledgement that the MCP server was unpublished.", "examples": [ { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", "deleted": true } } ] }, - "V2SecretWithValue": { + "WorkflowMcpTool": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Unique tool identifier." }, - "scope": { + "serverId": { "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": "Server that publishes this tool." }, - "description": { + "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" @@ -8397,180 +11224,201 @@ "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." + "description": "Description shown to MCP clients." }, - "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." + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." }, - "role": { + "apiEndpoint": { "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "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", - "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 tool was created.", + "format": "date-time" }, "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 tool was last modified.", + "format": "date-time" } }, "required": [ - "name", - "scope", - "description", - "unredacted", - "role", + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "updated", "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": "Workflow MCP tool", + "description": "A deployed workflow published as a tool on a workflow-MCP server." }, - "ListSecretsResponse": { + "DeployWorkflowMcpToolResponse": { "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." + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List secrets response", - "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", + "title": "Publish workflow as MCP tool response", + "description": "The published tool.", "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 + "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" + } } ] }, - "V2Secret": { + "DeployWorkflowMcpToolRequest": { "type": "object", "properties": { - "name": { + "workflowId": { "type": "string", "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Deployed workflow to publish. The workflow must already be deployed." }, - "scope": { + "toolName": { + "description": "Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.", "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." + "minLength": 1, + "maxLength": 128 }, - "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." + "toolDescription": { + "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", + "type": "string", + "maxLength": 2000 }, - "role": { + "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", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "description": "Identifier of the removed tool." }, - "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 secret was created." + "description": "Server the tool was removed from." }, - "updatedAt": { + "workflowId": { "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." + "description": "Workflow that is no longer published." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the tool was removed." } }, - "required": [ - "name", - "scope", - "description", - "unredacted", - "role", - "createdAt", - "updatedAt" - ], + "required": ["id", "serverId", "workflowId", "deleted"], "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." + "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 + } + } + ] }, - "SetSecretResponse": { + "UpdateCredentialResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2Credential" } }, "required": ["data"], "additionalProperties": false, - "title": "Set secret response", - "description": "Metadata for the created or replaced secret without its value.", + "title": "Update credential response", + "description": "Updated credential metadata without secret material.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "unredacted": false, + "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" @@ -8578,29 +11426,17 @@ } ] }, - "SetSecretRequest": { + "UpdateCredentialRequest": { "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, + "displayName": { + "description": "New name shown for the credential in Sim.", "type": "string", "minLength": 1, - "maxLength": 65536 + "maxLength": 255 }, "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.", + "description": "New credential description. Send null to clear the stored one.", "anyOf": [ { "type": "string", @@ -8611,973 +11447,1556 @@ } ] }, - "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" + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "atlassianProduct": { + "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", + "type": "string", + "enum": ["jira", "confluence"] + }, + "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": ["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.", + "title": "Update credential request", + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "value": "YOUR_SECRET_VALUE" - }, - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "unredacted": false + "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" } ] }, - "V2SecretDeleteData": { + "V2BlockSummary": { "type": "object", "properties": { + "id": { + "type": "string", + "description": "Block type identifier, used as a workflow block’s `type`." + }, "name": { "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Display name." }, - "scope": { + "description": { "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": "One-line summary of what the block does." }, - "deleted": { + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { + "type": "string", + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." + }, + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" + }, + "source": { + "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", - "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" + "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." + }, + "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": ["data"], + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags" + ], "additionalProperties": false, - "title": "Delete secret response", - "description": "Acknowledgement that the secret was deleted.", - "examples": [ - { - "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "deleted": true - } - } - ] + "title": "Block summary", + "description": "List view of a block: what it is and what it references, by id." }, - "V2Meta": { + "ListBlocksResponse": { "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." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockSummary" + }, + "description": "Items in the current page." }, - "expiresAt": { + "nextCursor": { "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": "string" }, { "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" + "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": "API capabilities response", - "description": "API availability, credential type, and expiry for the caller.", + "title": "List blocks response", + "description": "Blocks available in the workspace.", "examples": [ { - "data": { - "v2Enabled": true, - "keyType": "personal", - "expiresAt": null - } + "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 } ] }, - "WorkflowMcpServerListItem": { + "V2BlockField": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique workflow-MCP server identifier." + "description": "Field identifier, and the key its value is stored under." }, - "name": { + "type": { "type": "string", - "description": "Server display name, shown to connecting MCP clients." + "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": { - "anyOf": [ - { - "type": "string" + "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" + } }, - { - "type": "null" - } - ], - "description": "Optional server description, or null when unset." + "required": ["id"], + "additionalProperties": false + } }, - "isPublic": { - "type": "boolean", - "description": "Whether the server answers MCP clients without a Sim API key." + "min": { + "description": "Minimum accepted numeric value.", + "type": "number" }, - "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"] + "max": { + "description": "Maximum accepted numeric value.", + "type": "number" }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the server was created.", - "format": "date-time" + "step": { + "description": "Increment for numeric controls.", + "type": "number" }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the server was last modified.", - "format": "date-time" + "integer": { + "description": "Whether the numeric value must be a whole number.", + "type": "boolean" }, - "toolCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of workflows published as tools." + "rows": { + "description": "Visible row count for multi-line text.", + "type": "number" }, - "toolNames": { + "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" - }, - "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": { + } + }, + "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": { - "$ref": "#/components/schemas/WorkflowMcpServerListItem" - }, - "description": "Items in the current page." + "type": "string" + } }, - "nextCursor": { + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", "anyOf": [ { - "type": "string" + "type": "array", + "items": { + "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"] + "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 } - ], - "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." + "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" }, - "description": { + "defaultValue": { + "description": "Value used when the field is left unset.", "anyOf": [ { "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." + } } - ], - "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." - }, - "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, + "hasComputedDefault": { + "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", "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"], + "required": ["id", "type"], "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" - } - } - ] + "title": "Block field", + "description": "One configuration field on a block." }, - "WorkflowMcpToolListItem": { + "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." + "description": "Value, or set of accepted values, the named field must hold." }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to." + "not": { + "description": "Invert the match: every value EXCEPT `value`.", + "type": "boolean" }, - "apiEndpoint": { + "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" + } + ] + } + } + ] + }, + "not": { + "description": "Invert the second clause.", + "type": "boolean" + } + }, + "required": ["field"], + "additionalProperties": false + } + }, + "required": ["field", "value"], + "additionalProperties": false, + "title": "Catalog condition", + "description": "When a configuration field applies, expressed against a sibling field." + }, + "V2OperationInput": { + "type": "object", + "properties": { + "type": { "type": "string", - "description": "Sim execution endpoint this tool calls through." + "description": "Value type." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" + "required": { + "description": "Whether the value must be supplied.", + "type": "boolean" }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" + "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" + }, + "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": [ - "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "createdAt", - "updatedAt" - ], + "required": ["type"], "additionalProperties": false, - "title": "Workflow MCP tool list item", - "description": "A tool a server publishes, as returned by a read." + "title": "Operation input", + "description": "One value a block operation needs, from its tool or its block-level inputs." }, - "ListWorkflowMcpToolsResponse": { + "V2ToolOutput": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowMcpToolListItem" + "type": { + "type": "string", + "description": "Value type of the output field." + }, + "description": { + "description": "What the field holds.", + "type": "string" + }, + "optional": { + "description": "Whether the field may be absent.", + "type": "boolean" + }, + "nullable": { + "description": "Whether the field may be null.", + "type": "boolean" + }, + "properties": { + "description": "Members of an object-typed output, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" }, - "description": "Items in the current page." + "additionalProperties": { + "description": "Nested output field, in this same shape." + } }, - "nextCursor": { - "anyOf": [ - { + "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" }, - { - "type": "null" + "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." + } } - ], - "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": ["type"], + "additionalProperties": false }, - "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" + "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": ["data"], + "required": ["type"], "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" - } - } - ] + "title": "Tool output", + "description": "One declared output field of a built-in tool." }, - "UpdateWorkflowMcpServerRequest": { + "V2ToolDetail": { "type": "object", "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." + }, "name": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name, shown to connecting MCP clients." + "description": "Display name." }, "description": { - "description": "New server description, or null to clear it.", - "anyOf": [ - { + "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": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." + }, + "provider": { "type": "string", - "maxLength": 2000 + "description": "OAuth service the credential must authenticate." }, - { - "type": "null" + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } } - ] + }, + "required": ["required", "provider"], + "additionalProperties": false }, - "isPublic": { - "description": "Whether the server answers MCP clients without a Sim API key.", - "type": "boolean" + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolParam" + }, + "description": "Parameters the tool accepts." + }, + "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 workflow MCP server request", - "description": "Merge-patch body for a published MCP server.", - "examples": [ - { - "isPublic": true - } - ] + "title": "Tool", + "description": "A built-in tool with its declared parameters and outputs." }, - "DeleteWorkflowMcpServerResult": { + "V2ToolParam": { "type": "object", "properties": { - "id": { + "type": { "type": "string", - "description": "Identifier of the unpublished server." + "description": "Parameter value type." }, - "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": { + "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": ["data"], + "required": ["type"], "additionalProperties": false, - "title": "Delete workflow MCP server response", - "description": "Acknowledgement that the MCP server was unpublished.", - "examples": [ - { - "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "deleted": true - } - } - ] + "title": "Tool parameter", + "description": "One declared parameter of a built-in tool." }, - "WorkflowMcpTool": { + "V2BlockDetail": { "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." + }, + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { + "type": "string", + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." + }, + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" + }, + "source": { + "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." + }, + "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`." + }, + "bestPractices": { + "description": "Authored guidance on using the block correctly.", + "type": "string" }, - "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." + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that apply regardless of the selected operation." }, - "toolDescription": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "operationInputSchema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" } - ], - "description": "Description shown to MCP clients." + }, + "description": "Configuration fields keyed by the operation that reveals them." }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to." + "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." }, - "apiEndpoint": { - "type": "string", - "description": "Sim execution endpoint this tool calls through." + "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." }, - "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." + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolDetail" + }, + "description": "Every built-in tool the block can run, with parameters and outputs." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" + "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." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" + "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." } }, "required": [ "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "updated", - "createdAt", - "updatedAt" + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags", + "inputSchema", + "operationInputSchema", + "inputDefinitions", + "operations", + "tools", + "triggers", + "outputs" ], "additionalProperties": false, - "title": "Workflow MCP tool", - "description": "A deployed workflow published as a tool on a workflow-MCP server." + "title": "Block", + "description": "A block with its configuration fields, operations, tools, and triggers." }, - "DeployWorkflowMcpToolResponse": { + "GetBlockResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpTool" + "$ref": "#/components/schemas/V2BlockDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Publish workflow as MCP tool response", - "description": "The published tool.", + "title": "Get block response", + "description": "One block with its fields, operations, tools, and triggers.", "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": { + "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", - "minLength": 1, - "maxLength": 2000, - "description": "Text MCP clients see for that field." + "description": "Channel to post into." } }, - "required": ["name", "description"], - "additionalProperties": false + "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." + } + } + } + ], + "triggers": [ + { + "id": "slack_webhook", + "outputs": { + "text": { + "type": "string", + "description": "Message text." + } + }, + "configFields": { + "channels": { + "type": "short-input", + "required": false, + "title": "Channels" + } + } + } + ], + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } } } - }, - "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": { + "V2ToolSummary": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the removed tool." + "description": "Registered tool identifier, including its version suffix." }, - "serverId": { + "name": { "type": "string", - "description": "Server the tool was removed from." + "description": "Display name." }, - "workflowId": { + "description": { "type": "string", - "description": "Workflow that is no longer published." + "description": "What the tool does." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the tool was removed." + "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": { + "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 } }, - "required": ["id", "serverId", "workflowId", "deleted"], + "required": ["id", "name", "description", "hostedApiKey"], "additionalProperties": false, - "title": "Unpublish workflow MCP tool result", - "description": "Tool removal acknowledgement." + "title": "Tool summary", + "description": "List view of a built-in tool: identity, auth, and key hosting." }, - "UndeployWorkflowMcpToolResponse": { + "ListToolsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" + "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." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Unpublish workflow MCP tool response", - "description": "Acknowledgement that the tool was removed.", + "title": "List tools response", + "description": "Built-in tools available in the workspace.", "examples": [ { - "data": { - "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", - "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true - } + "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 } ] }, - "UpdateCredentialResponse": { + "GetToolResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2ToolDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Update credential response", - "description": "Updated credential metadata without secret material.", + "title": "Get tool response", + "description": "One built-in tool with its parameters and outputs.", "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" + "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": { + "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." + } + } } } ] }, - "UpdateCredentialRequest": { + "V2ToolExecution": { "type": "object", "properties": { - "displayName": { - "description": "New name shown for the credential in Sim.", + "toolId": { "type": "string", - "minLength": 1, - "maxLength": 255 + "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." }, - "description": { - "description": "New credential description. Send null to clear the stored one.", + "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": "string", - "maxLength": 500 + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Why the tool call did not succeed." + } + }, + "required": ["message"], + "additionalProperties": false }, { "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 - }, - "domain": { - "description": "Provider account domain.", - "type": "string", - "minLength": 1, - "maxLength": 2048 - }, - "atlassianProduct": { - "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", - "type": "string", - "enum": ["jira", "confluence"] - }, - "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, + ], + "description": "Populated only when `status` is `failed`." + } + }, + "required": ["toolId", "status", "output", "error"], + "additionalProperties": false, + "title": "Tool execution", + "description": "The result of running one built-in tool." + }, + "ExecuteToolResponse": { + "type": "object", + "properties": { + "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", "minLength": 1, - "maxLength": 8192 + "maxLength": 128, + "description": "Workspace whose integration allowlist, credentials, and environment variables govern this call." }, - "username": { - "description": "Provider run-as username.", + "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." + } + }, + "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": "Update credential request", - "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "title": "Run tool request", + "description": "Workspace, arguments, and the credential to authenticate with.", "examples": [ { - "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "input": { + "channel": "C0123456789", + "text": "Deploy finished." + }, + "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" } ] }, - "V2BlockSummary": { + "V2ConnectorType": { "type": "object", "properties": { - "id": { + "connectorType": { "type": "string", - "description": "Block type identifier, used as a workflow block’s `type`." + "description": "Exact identifier to send when creating a connector of this type." }, "name": { "type": "string", @@ -9585,205 +13004,144 @@ }, "description": { "type": "string", - "description": "One-line summary of what the block does." - }, - "longDescription": { - "description": "Extended explanation, when the block has one.", - "type": "string" - }, - "category": { - "type": "string", - "description": "Toolbar category: `blocks`, `tools`, or `triggers`." - }, - "integrationType": { - "description": "Integration category, e.g. `communication`, `databases`.", - "type": "string" + "description": "What the connector syncs." }, - "source": { + "version": { "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." + "description": "Connector version." }, - "toolIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Built-in tools this block can run. Read a tool by its id for the full definition." + "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." }, - "operationIds": { + "configFields": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/V2ConnectorConfigField" }, - "description": "Operations this block exposes. Their fields and tools are on the block read." + "description": "Fields that make up the connector’s `sourceConfig`." }, - "preview": { + "supportsIncrementalSync": { "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" + "description": "Whether syncs after the first fetch only what changed." }, - "tags": { + "tagDefinitions": { "type": "array", "items": { - "type": "string" + "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": "Catalog tags, e.g. `messaging`, `version-control`." + "description": "Tags this connector writes onto the documents it syncs." } }, "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 - } - ] + "connectorType", + "name", + "description", + "version", + "auth", + "configFields", + "supportsIncrementalSync", + "tagDefinitions" + ], + "additionalProperties": false, + "title": "Connector type", + "description": "A knowledge-base connector type and the configuration it accepts." }, - "V2BlockField": { + "V2ConnectorConfigField": { "type": "object", "properties": { "id": { "type": "string", - "description": "Field identifier, and the key its value is stored under." + "description": "Field identifier." + }, + "title": { + "type": "string", + "description": "Human-readable label." }, "type": { "type": "string", - "description": "Editor control the field renders as, e.g. `short-input`." + "enum": ["short-input", "dropdown", "selector"], + "description": "Control the field renders as. A `selector` fetches its options from the connected account." }, - "title": { - "description": "Human-readable label.", + "placeholder": { + "description": "Placeholder shown in the editor.", "type": "string" }, "required": { - "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "description": "Whether a value must be supplied.", "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.", + "description": "Static options, for a `dropdown` field.", "type": "array", "items": { "type": "object", @@ -9793,92 +13151,22 @@ "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" + "type": "string", + "description": "Human-readable option label." } }, - "required": ["id"], + "required": ["id", "label"], "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.", + "selectorKey": { + "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", "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.", + "description": "MIME type filter applied to the picker.", "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": [ @@ -9898,926 +13186,1699 @@ "type": "string" } }, - "any": { - "description": "At least one listed field must hold a value.", + "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.", + "type": "string", + "enum": ["basic", "advanced"] + }, + "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", "title", "type"], + "additionalProperties": false, + "title": "Connector config field", + "description": "One field of a knowledge-base connector’s source configuration." + }, + "ListConnectorTypesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorType" + }, + "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 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 + } + ] + }, + "V2PermissionGroup": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Permission group identifier." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the group." + }, + "name": { + "type": "string", + "description": "Group name, unique within the organization." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional description of the group." + }, + "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" } - }, - "additionalProperties": false - } - ] - }, - "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" - }, - { - "type": "number" - }, - { - "type": "boolean" + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." }, - { - "type": "object", - "propertyNames": { + "deniedModels": { + "default": [], + "type": "array", + "items": { "type": "string" }, - "additionalProperties": { - "description": "Member of an object-valued default. Shape varies by field type." - } + "description": "Models listed in this list are blocked." }, - { + "deniedTools": { + "default": [], "type": "array", "items": { - "description": "Element of an array-valued default. Shape varies by field type." - } - } - ] - }, - "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": "Block field", - "description": "One configuration field on a block." - }, - "V2CatalogCondition": { - "type": "object", - "properties": { - "field": { - "type": "string", - "description": "Sibling field id whose value decides this condition." - }, - "value": { - "anyOf": [ - { - "type": "string" + "type": "string" + }, + "description": "Integration tools listed in this list are blocked." }, - { - "type": "number" + "hideTraceSpans": { + "type": "boolean", + "description": "Withhold per-block trace spans from logs and from the API." }, - { - "type": "boolean" + "hideKnowledgeBaseTab": { + "type": "boolean", + "description": "Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base." }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - } - ], - "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." + "hideTablesTab": { + "type": "boolean", + "description": "Revoke the Tables module. Members cannot read or write any table." }, - "value": { - "description": "Value the second clause matches. Absent means \"holds any value\".", + "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": "string" + "type": "array", + "items": { + "type": "string", + "enum": ["public", "password", "email", "sso"] + } }, { - "type": "number" - }, + "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": "boolean" + "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": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] + "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." + }, + "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": [ + "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." + }, + "isDefault": { + "type": "boolean", + "description": "Whether this is the organization default, which applies to everyone across all its workspaces regardless of member assignments." + }, + "membershipMode": { + "type": "string", + "description": "An empty inherit group governs everyone in its workspaces; an empty explicit group governs nobody." + }, + "workspaceIds": { + "type": "array", + "items": { + "type": "string" + }, + "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", + "config", + "isDefault", + "membershipMode", + "workspaceIds", + "createdBy", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Permission group", + "description": "An organization permission group and its resolved restrictions." + }, + "ListPermissionGroupsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2PermissionGroup" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "not": { - "description": "Invert the second clause.", - "type": "boolean" + { + "type": "null" } - }, - "required": ["field"], - "additionalProperties": false + ], + "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": ["field", "value"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Catalog condition", - "description": "When a configuration field applies, expressed against a sibling field." + "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 + } + ] }, - "V2OperationInput": { + "CreatePermissionGroupResponse": { "type": "object", "properties": { - "type": { - "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" - }, - "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." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroup" } }, - "required": ["type"], + "required": ["data"], "additionalProperties": false, - "title": "Operation input", - "description": "One value a block operation needs, from its tool or its block-level inputs." + "title": "Create Permission Group response", + "description": "Create Permission Group 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" + } + } + ] }, - "V2ToolOutput": { + "CreatePermissionGroupRequest": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "description": "Value type of the output field." + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." }, "description": { - "description": "What the field holds.", - "type": "string" - }, - "optional": { - "description": "Whether the field may be absent.", - "type": "boolean" - }, - "nullable": { - "description": "Whether the field may be null.", - "type": "boolean" - }, - "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." - } + "description": "Optional group description.", + "type": "string", + "maxLength": 500 }, - "items": { - "description": "Element shape of an array-typed output.", + "config": { "type": "object", "properties": { - "type": { - "type": "string", - "description": "Element value type." + "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." }, - "description": { - "description": "What an element holds.", - "type": "string" + "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." }, - "properties": { - "description": "Members of an object-typed element, keyed by field name.", - "type": "object", - "propertyNames": { + "deniedModels": { + "type": "array", + "items": { "type": "string" }, - "additionalProperties": { - "description": "Nested output field, in this same shape." - } - } - }, - "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" + "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." + }, + "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." }, - "extension": { - "description": "File extension of the produced file.", - "type": "string" + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." } }, - "additionalProperties": false + "additionalProperties": false, + "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 IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, - "required": ["type"], + "required": ["name"], "additionalProperties": false, - "title": "Tool output", - "description": "One declared output field of a built-in tool." + "title": "Create Permission Group request", + "description": "Create Permission Group inputs.", + "examples": [ + { + "name": "Restricted", + "workspaceIds": ["workspace-123"] + } + ] }, - "V2ToolDetail": { + "GetPermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroup" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Permission Group response", + "description": "Get Permission Group 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" + } + } + ] + }, + "UpdatePermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroup" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update Permission Group response", + "description": "Update Permission Group 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" + } + } + ] + }, + "UpdatePermissionGroupRequest": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Registered tool identifier, including its version suffix." - }, "name": { "type": "string", - "description": "Display name." + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." }, "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." + "description": "Group description. Null or an empty string clears it; omission leaves it unchanged.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] }, - "oauth": { - "description": "OAuth requirement, when the tool has one.", + "config": { "type": "object", "properties": { - "required": { - "type": "boolean", - "description": "Whether the tool cannot run without an OAuth credential." + "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." }, - "provider": { - "type": "string", - "description": "OAuth service the credential must authenticate." + "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." }, - "requiredScopes": { - "description": "Scopes the credential must carry.", + "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", + "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." + }, + "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." }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolParam" - }, - "description": "Parameters the tool accepts." + "isDefault": { + "description": "Whether the group is the organization default. Only one group can be the default.", + "type": "boolean" }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolOutput" - }, - "description": "Fields the tool produces." + "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", "name", "description", "hostedApiKey", "params", "outputs"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Tool", - "description": "A built-in tool with its declared parameters and outputs." + "title": "Permission group deletion", + "description": "Acknowledges permanent group deletion." }, - "V2ToolParam": { + "DeletePermissionGroupResponse": { "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." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupDeletion" } }, - "required": ["type"], + "required": ["data"], "additionalProperties": false, - "title": "Tool parameter", - "description": "One declared parameter of a built-in tool." + "title": "Delete Permission Group response", + "description": "Delete Permission Group result.", + "examples": [ + { + "data": { + "id": "group-123", + "deleted": true + } + } + ] }, - "V2BlockDetail": { + "V2PermissionGroupMember": { "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": "Membership assignment identifier." }, - "longDescription": { - "description": "Extended explanation, when the block has one.", - "type": "string" - }, - "category": { + "userId": { "type": "string", - "description": "Toolbar category: `blocks`, `tools`, or `triggers`." - }, - "integrationType": { - "description": "Integration category, e.g. `communication`, `databases`.", - "type": "string" + "description": "Organization member assigned to the group." }, - "source": { + "assignedAt": { "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." + "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." }, - "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.", + "userName": { + "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": "Member display name." }, - "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." - } + "userEmail": { + "anyOf": [ + { + "type": "string" }, - "required": ["type"], - "additionalProperties": false - }, - "description": "Block-level input definitions, keyed by parameter name." + { + "type": "null" + } + ], + "description": "Member email address." }, - "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." - } + "userImage": { + "anyOf": [ + { + "type": "string" }, - "required": ["inputs", "outputs", "inputSchema"], - "additionalProperties": false - }, - "description": "Operations the block exposes, keyed by operation id." - }, - "tools": { + { + "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/V2ToolDetail" + "$ref": "#/components/schemas/V2PermissionGroupMember" }, - "description": "Every built-in tool the block can run, with parameters and outputs." + "description": "Items in the current page." }, - "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." - } + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "required": ["id", "outputs", "configFields"], - "additionalProperties": false - }, - "description": "Triggers the block can run on." + { + "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." }, - "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." + "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", - "name", - "description", - "category", - "source", - "triggerAllowed", - "triggerCapable", - "triggerIds", - "toolIds", - "operationIds", - "preview", - "tags", - "inputSchema", - "operationInputSchema", - "inputDefinitions", - "operations", - "tools", - "triggers", - "outputs" + "permissionGroupId", + "organizationId", + "userId", + "assignedBy", + "assignedAt" ], "additionalProperties": false, - "title": "Block", - "description": "A block with its configuration fields, operations, tools, and triggers." + "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 + } + } + ] }, - "GetBlockResponse": { + "BulkAddPermissionGroupMembersRequest": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2BlockDetail" + "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" } }, - "required": ["data"], "additionalProperties": false, - "title": "Get block response", - "description": "One block with its fields, operations, tools, and triggers.", + "title": "Bulk Add Permission Group Members request", + "description": "Bulk Add Permission Group Members inputs.", "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." - } - } - } - ], - "triggers": [ - { - "id": "slack_webhook", - "outputs": { - "text": { - "type": "string", - "description": "Message text." - } - }, - "configFields": { - "channels": { - "type": "short-input", - "required": false, - "title": "Channels" - } - } - } - ], - "outputs": { - "ts": { - "type": "string", - "description": "Message timestamp." - } - } - } + "userIds": ["user-123"] } ] }, - "V2ToolSummary": { + "V2Organization": { "type": "object", "properties": { "id": { "type": "string", - "description": "Registered tool identifier, including its version suffix." + "description": "Organization identifier." }, "name": { "type": "string", - "description": "Display name." + "description": "Organization display name." }, - "description": { + "slug": { "type": "string", - "description": "What the tool does." + "description": "Organization slug." }, - "version": { - "description": "Tool version.", - "type": "string" + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization logo URL, or null when unset." }, - "hostedApiKey": { + "role": { "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." + "enum": ["owner", "admin", "member"], + "description": "The acting user’s role in this organization." }, - "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 + "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", "description", "hostedApiKey"], + "required": ["id", "name", "slug", "logo", "role", "createdAt"], "additionalProperties": false, - "title": "Tool summary", - "description": "List view of a built-in tool: identity, auth, and key hosting." + "title": "Organization", + "description": "An organization the acting user belongs to." }, - "ListToolsResponse": { + "ListOrganizationsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ToolSummary" + "$ref": "#/components/schemas/V2Organization" }, "description": "Items in the current page." }, @@ -10835,421 +14896,329 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List tools response", - "description": "Built-in tools available in the workspace.", + "title": "List Organizations response", + "description": "List Organizations 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": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" } ], "nextCursor": null } ] }, - "GetToolResponse": { + "GetOrganizationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ToolDetail" + "$ref": "#/components/schemas/V2Organization" } }, "required": ["data"], "additionalProperties": false, - "title": "Get tool response", - "description": "One built-in tool with its parameters and outputs.", + "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" + }, + "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 Workspaces response", + "description": "List Organization Workspaces 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"] - }, - "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." - } + "data": [ + { + "id": "workspace-123", + "name": "Engineering" } - } + ], + "nextCursor": null } ] }, - "V2ToolExecution": { + "V2OrganizationMember": { "type": "object", "properties": { - "toolId": { + "userId": { "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." + "description": "User identifier; use this identifier to update or remove the member." }, - "status": { + "name": { "type": "string", - "enum": ["succeeded", "failed"], - "description": "Whether the tool reported success. A failed tool call is still a 200." + "description": "Member display name." }, - "output": { - "description": "Whatever the tool produced, shaped by its declared outputs." + "email": { + "type": "string", + "description": "Member email address." }, - "error": { + "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": { + "$ref": "#/components/schemas/V2OrganizationMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { "anyOf": [ { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Why the tool call did not succeed." - } - }, - "required": ["message"], - "additionalProperties": false + "type": "string" }, { "type": "null" } ], - "description": "Populated only when `status` is `failed`." + "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": ["toolId", "status", "output", "error"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Tool execution", - "description": "The result of running one built-in tool." + "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 + } + ] }, - "ExecuteToolResponse": { + "UpdateOrganizationMemberResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ToolExecution" + "$ref": "#/components/schemas/V2OrganizationMember" } }, "required": ["data"], "additionalProperties": false, - "title": "Run tool response", - "description": "What the tool produced, or why it did not succeed.", + "title": "Update Organization Member response", + "description": "Update Organization Member result.", "examples": [ { "data": { - "toolId": "slack_message", - "status": "succeeded", - "output": { - "ts": "1718191234.004500" - }, - "error": null + "userId": "user-123", + "name": "Example Member", + "email": "member@example.com", + "role": "admin", + "joinedAt": "2026-06-01T09:00:00.000Z" } } ] }, - "ExecuteToolRequest": { + "UpdateOrganizationMemberBody": { "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." - } - }, - "credentialId": { - "description": "Credential to authenticate with. Required when the tool declares an OAuth requirement; the workspace credentials list names the candidates.", + "role": { "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 + "enum": ["member", "admin"], + "description": "New organization role. Ownership transfers use a separate operation." } }, - "required": ["workspaceId"], + "required": ["role"], "additionalProperties": false, - "title": "Run tool request", - "description": "Workspace, arguments, and the credential to authenticate with.", + "title": "Update Organization Member body", + "description": "Update Organization Member input.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "input": { - "channel": "C0123456789", - "text": "Deploy finished." - }, - "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" + "role": "admin" } ] }, - "V2ConnectorType": { + "V2OrganizationMemberDeletion": { "type": "object", "properties": { - "connectorType": { + "userId": { "type": "string", - "description": "Exact identifier to send when creating a connector of this type." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "description": { - "type": "string", - "description": "What the connector syncs." - }, - "version": { - "type": "string", - "description": "Connector version." - }, - "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." - }, - "configFields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ConnectorConfigField" - }, - "description": "Fields that make up the connector’s `sourceConfig`." + "description": "User removed from the organization." }, - "supportsIncrementalSync": { + "deleted": { "type": "boolean", - "description": "Whether syncs after the first fetch only what changed." - }, - "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." + "const": true, + "description": "Whether membership and organization workspace access were removed." } }, - "required": [ - "connectorType", - "name", - "description", - "version", - "auth", - "configFields", - "supportsIncrementalSync", - "tagDefinitions" - ], + "required": ["userId", "deleted"], + "additionalProperties": false, + "title": "Organization member removal", + "description": "Acknowledges removal of an organization member." + }, + "RemoveOrganizationMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationMemberDeletion" + } + }, + "required": ["data"], "additionalProperties": false, - "title": "Connector type", - "description": "A knowledge-base connector type and the configuration it accepts." + "title": "Remove Organization Member response", + "description": "Remove Organization Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "deleted": true + } + } + ] }, - "V2ConnectorConfigField": { + "V2OrganizationInvitation": { "type": "object", "properties": { "id": { "type": "string", - "description": "Field identifier." + "description": "Invitation identifier." }, - "title": { + "organizationId": { "type": "string", - "description": "Human-readable label." + "description": "Organization that owns the invitation." }, - "type": { + "email": { "type": "string", - "enum": ["short-input", "dropdown", "selector"], - "description": "Control the field renders as. A `selector` fetches its options from the connected account." - }, - "placeholder": { - "description": "Placeholder shown in the editor.", - "type": "string" - }, - "required": { - "description": "Whether a value must be supplied.", - "type": "boolean" + "description": "Email address of the invitee." }, - "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." - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "selectorKey": { - "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", - "type": "string" + "role": { + "type": "string", + "enum": ["member", "admin"], + "description": "Organization role offered to an internal invitee." }, - "mimeType": { - "description": "MIME type filter applied to the picker.", - "type": "string" + "kind": { + "type": "string", + "enum": ["organization", "workspace"], + "description": "Whether the invitation originated from organization or workspace administration." }, - "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 - } - ] + "membershipIntent": { + "type": "string", + "enum": ["internal", "external"], + "description": "Whether acceptance joins the organization or grants workspace access only." }, - "mode": { - "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "status": { "type": "string", - "enum": ["basic", "advanced"] + "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], + "description": "Current invitation status; elapsed pending invitations are reported as expired." }, - "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" + "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." }, - "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" + "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", "title", "type"], + "required": [ + "id", + "organizationId", + "email", + "role", + "kind", + "membershipIntent", + "status", + "createdAt", + "expiresAt" + ], "additionalProperties": false, - "title": "Connector config field", - "description": "One field of a knowledge-base connector’s source configuration." + "title": "Organization invitation", + "description": "Invitation metadata without its acceptance token." }, - "ListConnectorTypesResponse": { + "ListOrganizationInvitationsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ConnectorType" + "$ref": "#/components/schemas/V2OrganizationInvitation" }, "description": "Items in the current page." }, @@ -11262,59 +15231,192 @@ "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 Organization Invitations response", + "description": "List Organization Invitations 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": "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 } ] + }, + "CreateOrganizationInvitationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitation" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create Organization Invitation response", + "description": "Create Organization Invitation result.", + "examples": [ + { + "data": { + "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" + } + } + ] + }, + "CreateOrganizationInvitationBody": { + "type": "object", + "properties": { + "email": { + "type": "string", + "minLength": 1, + "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": ["email"], + "additionalProperties": false, + "title": "Create Organization Invitation body", + "description": "Create Organization Invitation input.", + "examples": [ + { + "email": "member@example.com", + "role": "member" + } + ] + }, + "GetOrganizationInvitationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitation" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Organization Invitation response", + "description": "Get Organization Invitation result.", + "examples": [ + { + "data": { + "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" + } + } + ] + }, + "ResendOrganizationInvitationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitation" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Resend Organization Invitation response", + "description": "Resend Organization Invitation result.", + "examples": [ + { + "data": { + "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" + } + } + ] + }, + "ResendOrganizationInvitationBody": { + "default": {}, + "title": "Resend Organization Invitation body", + "description": "Resend Organization Invitation input.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "V2OrganizationInvitationRevocation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Revoked invitation identifier." + }, + "status": { + "type": "string", + "const": "cancelled", + "description": "Revocation cancels the invitation and prevents acceptance." + } + }, + "required": ["id", "status"], + "additionalProperties": false, + "title": "Organization invitation revocation", + "description": "Acknowledges cancellation of a pending invitation." + }, + "RevokeOrganizationInvitationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitationRevocation" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revoke Organization Invitation response", + "description": "Revoke Organization Invitation result.", + "examples": [ + { + "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..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,7 +1,9 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest } from '@sim/testing' +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' const { @@ -15,7 +17,7 @@ const { mockValidateInvitationsAllowed, mockSendInvitationEmail, mockPrepareInvitationResend, - mockPersistInvitationResend, + mockRevertInvitationResend, mockGetOrganizationSubscription, } = vi.hoisted(() => ({ MockInvitationsNotAllowedError: class extends Error { @@ -33,7 +35,7 @@ const { mockValidateInvitationsAllowed: vi.fn(), mockSendInvitationEmail: vi.fn(), mockPrepareInvitationResend: vi.fn(), - mockPersistInvitationResend: vi.fn(), + mockRevertInvitationResend: vi.fn(), mockGetOrganizationSubscription: vi.fn(), })) @@ -51,11 +53,12 @@ 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, prepareInvitationResend: mockPrepareInvitationResend, - persistInvitationResend: mockPersistInvitationResend, + revertInvitationResend: mockRevertInvitationResend, })) vi.mock('@/lib/billing/core/organization', () => ({ isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, @@ -69,8 +72,16 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ })) vi.mock('@/lib/workspaces/policy', () => ({ getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, + WORKSPACE_MODE: { ORGANIZATION: 'organization' }, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), +})) + +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' const mockGetSession = authMockFns.mockGetSession @@ -94,11 +105,24 @@ 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' }], } +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 @@ -107,7 +131,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) @@ -115,26 +145,40 @@ 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({ - tokenForEmail: 'token-2', - nextToken: 'token-2', - nextExpiresAt: new Date('2026-09-30T00:00:00.000Z'), + mockPrepareInvitationResend.mockImplementation(async (params) => { + await lockInvitationResendPolicy( + db, + await mockGetInvitationById(params.invitationId), + params.actorUserId, + params.expectedOrganizationId + ) + return preparedResend }) mockSendInvitationEmail.mockResolvedValue({ success: true }) - mockPersistInvitationResend.mockResolvedValue(undefined) + mockRevertInvitationResend.mockResolvedValue(true) }) it('resends when no group withholds invitations', async () => { 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] + ) }) /** @@ -148,12 +192,12 @@ 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' }, }) expect(mockSendInvitationEmail).not.toHaveBeenCalled() - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() }) /** @@ -161,6 +205,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) @@ -183,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 () => { @@ -203,7 +257,7 @@ describe('POST /api/invitations/[id]/resend', () => { expect(response.status).toBe(403) expect(mockSendInvitationEmail).not.toHaveBeenCalled() - expect(mockPersistInvitationResend).not.toHaveBeenCalled() + expect(mockRevertInvitationResend).not.toHaveBeenCalled() }) /** @@ -218,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 () => { @@ -238,7 +303,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,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 () => { @@ -271,8 +340,51 @@ 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', + async (status) => { + mockGetInvitationById.mockResolvedValue({ + ...workspaceInvitation, + status, + expiresAt: new Date('2000-01-01'), + }) + expect((await callResend()).status).toBe(400) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + } + ) + + it('restores the previous token when delivery fails', async () => { + mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'Delivery unavailable' }) + expect((await callResend()).status).toBe(502) + 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/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]/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..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 @@ -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) { @@ -69,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() @@ -86,13 +96,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 +124,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 +139,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 +153,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,8 +162,10 @@ describe('permission group PUT policy serialization', () => { }) expect(response.status).toBe(404) - await expect(response.json()).resolves.toEqual({ error: 'Permission group not found' }) - expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) + await expect(response.json()).resolves.toMatchObject({ error: 'Permission group not found' }) + 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/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]/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/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]/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/[userId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route.ts new file mode 100644 index 00000000000..895bb08de90 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/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: { userId: member.userId, 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]/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..a29caac991a --- /dev/null +++ b/apps/sim/app/api/v2/organizations/route.test.ts @@ -0,0 +1,183 @@ +/** @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 { + 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' + +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(['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) => { + 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/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/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/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 6d80ee68d18..96f3b4b3332 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). */ @@ -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 }, @@ -134,22 +134,64 @@ 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 or an empty string 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 +237,7 @@ export const createPermissionGroupContract = defineRouteContract({ schema: z.object({ permissionGroup: permissionGroupWriteSchema, }), + status: 201, }, }) @@ -208,6 +251,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 +329,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..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,6 +45,13 @@ 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/audit-logs', 'GET /api/v2/billing/logs', 'GET /api/v2/blocks', @@ -147,6 +154,21 @@ 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': ['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]/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', + ], + 'GET /api/v2/audit-logs': [ 'includeDeparted', 'action', @@ -289,6 +311,15 @@ 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/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/organizations.ts b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts new file mode 100644 index 00000000000..56e15e7b9a0 --- /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. 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: { + 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 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: { + 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 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: { + 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 new file mode 100644 index 00000000000..137ab4a6d1d --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/permission-groups.ts @@ -0,0 +1,358 @@ +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, + 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', + 'Organization parameters', + 'Organization identifier.' + ), + 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', + 'Organization parameters', + 'Organization identifier.' + ), + 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 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: { + 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: { userId: 'user-123', deleted: true } }] + ), + } + ), + defineOpenApiRoute( + v2BulkAddPermissionGroupMembersContract, + { + applicationOperation: permissionGroupOperations.bulkAddMembers, + operationId: 'bulkAddPermissionGroupMembers', + summary: 'Bulk Add Permission Group Members', + 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: { + 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 } }] + ), + } + ), +] 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..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,8 @@ 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, type ErrorResponseId, @@ -2147,6 +2149,8 @@ const declaredRoutes = [ ), } ), + ...permissionGroupOpenApiRoutes, + ...organizationOpenApiRoutes, ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -2154,9 +2158,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 +2174,14 @@ 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.', + }, { name: 'Meta', description: 'Discover what the calling API credential can reach.', 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..05cbe292d40 --- /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.'), + 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 new file mode 100644 index 00000000000..26b5324f7f0 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/permission-groups.ts @@ -0,0 +1,311 @@ +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({ + userId: nonEmptyIdSchema.describe('User identifier of the member to remove.'), +}) +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 to everyone across all its workspaces regardless of member assignments.' + ), + 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 +> + +const configPatchSchema = permissionGroupConfigSchema.strict() + +export const v2CreatePermissionGroupBodySchema = createPermissionGroupBodySchema + .safeExtend({ + 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), { + 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 + .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), { + 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 in bounded batches within one transaction. 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/[userId]', + params: v2PermissionGroupMemberParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + userId: z.string().describe('User whose membership assignment was removed.'), + 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.', + }) + ), + }, +}) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 741164dd97e..fef2ce65fb1 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -135,6 +135,30 @@ 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, + v2CreatePermissionGroupContract, + v2DeletePermissionGroupContract, + v2GetPermissionGroupContract, + v2ListPermissionGroupMembersContract, + v2ListPermissionGroupsContract, + v2RemovePermissionGroupMemberContract, + v2UpdatePermissionGroupContract, +} from '@/lib/api/contracts/v2/permission-groups' import { v2CreateSandboxContract, v2DeleteSandboxContract, @@ -317,6 +341,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 +395,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 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( + '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route' + ).then((route) => route.POST), + }, bulkDeleteFiles: { contract: v2BulkDeleteFilesContract, summary: 'Delete Files', @@ -603,6 +649,28 @@ 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. 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( + (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 +890,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 +1219,37 @@ 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', + 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 +1635,69 @@ 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', + 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 + ), + }, listSandboxes: { contract: v2ListSandboxesContract, summary: 'List Sandboxes', @@ -1844,6 +2017,28 @@ 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 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/[userId]/route' + ).then((route) => route.DELETE), + }, renameFile: { contract: v2RenameFileContract, summary: 'Rename File', @@ -1871,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 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( + '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route' + ).then((route) => route.POST), + }, restoreFile: { contract: v2RestoreFileContract, summary: 'Restore File', @@ -1947,6 +2153,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + revokeOrganizationInvitation: { + contract: v2RevokeOrganizationInvitationContract, + summary: 'Revoke Organization Invitation', + 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`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/route').then( + (route) => route.DELETE + ), + }, revokeSkillEditor: { contract: v2RevokeSkillEditorContract, summary: 'Revoke Skill Editor', @@ -2168,6 +2385,28 @@ 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', + 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/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/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/organizations.ts b/apps/sim/lib/api/server/routes/organizations.ts new file mode 100644 index 00000000000..6327fd84a62 --- /dev/null +++ b/apps/sim/lib/api/server/routes/organizations.ts @@ -0,0 +1,72 @@ +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 { 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' +import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' + +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) + 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/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/billing/organizations/membership-external-removal.test.ts b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts index c68b4662235..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(), @@ -84,4 +85,73 @@ 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', + 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' }]) + await expect( + removeUserFromOrganization({ + organizationId: 'org-1', + userId: 'target', + memberId: 'membership', + actorUserId: 'actor', + onError: 'throw', + }) + ).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..c28c416b8b2 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,12 @@ 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 + /** 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 @@ -1279,6 +1288,9 @@ export async function removeUserFromOrganization( requireNoOrgWorkspaceAccess = false, revokePersonalApiKeys = false, spareSessionToken, + spareSessionId, + actorUserId, + onError = 'return-failure', } = params const billingActions = { @@ -1311,6 +1323,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 +1349,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 +1406,7 @@ export async function removeUserFromOrganization( userId, organizationId, ...(spareSessionToken ? { spareSessionToken } : {}), + ...(spareSessionId ? { spareSessionId } : {}), }) if (revokePersonalApiKeys) await revokePersonalApiKeysTx(tx, { userId }) await endDirectoryMembershipTx(tx, { userId, organizationId }) @@ -1519,6 +1535,7 @@ export async function removeUserFromOrganization( 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, @@ -1537,6 +1554,7 @@ export async function removeUserFromOrganization( export async function removeExternalUserFromOrganizationWorkspaces(params: { userId: string organizationId: string + actorUserId?: string }): Promise { const { userId, organizationId } = params @@ -1566,12 +1584,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 +1710,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 d62ef97b64e..c252b9ae76e 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -38,7 +38,9 @@ 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', 'cli/reference.mdx', 'cli/sandboxes.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..c622e55e7ab --- /dev/null +++ b/apps/sim/lib/invitations/application/mutations.test.ts @@ -0,0 +1,205 @@ +/** @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.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', + }) + 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..ab01b731ce1 --- /dev/null +++ b/apps/sim/lib/invitations/application/mutations.ts @@ -0,0 +1,122 @@ +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.kind === 'organization' ? null : (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.kind === 'organization' ? null : (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.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 2316d269116..2bd155871f3 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' } @@ -1746,9 +1778,12 @@ 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, + requireUnexpired: true, }) if (!revoked.revoked) return { success: false, kind: 'not-cancellable' } return { @@ -1777,10 +1812,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' } @@ -1809,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 @@ -1827,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/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/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 new file mode 100644 index 00000000000..94ec149f89a --- /dev/null +++ b/apps/sim/lib/invitations/mutation-manager.ts @@ -0,0 +1,108 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type InvitationWithGrants, revokeInvitationAsAdmin } from '@/lib/invitations/core' +import { InvitationNotPendingError } from '@/lib/invitations/errors' +import { + prepareInvitationResend, + revertInvitationResend, + sendInvitationEmail, +} from '@/lib/invitations/send' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' + +const logger = createLogger('InvitationMutationManager') + +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 InvitationNotPendingError('resend') + 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, + expectedOrganizationId: input.assertedOrganizationId, + expectedUpdatedAt: inv.updatedAt, + actorUserId: input.actorUserId, + }) + 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, + })), + }).catch((error: unknown) => { + logger.error('Invitation resend delivery failed', { invitationId: inv.id, error }) + return { success: false } + }) + if (!delivered.success) { + const reverted = await revertInvitationResend(resend) + throw new WorkspaceInvitationError({ + status: reverted ? 502 : 409, + message: reverted + ? 'Failed to send invitation email. Please try again.' + : 'The invitation changed while delivery failed. Refresh before resending.', + }) + } + 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 InvitationNotPendingError('revoke') + } + 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/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 new file mode 100644 index 00000000000..da5ded3362f --- /dev/null +++ b/apps/sim/lib/invitations/send-resend.test.ts @@ -0,0 +1,173 @@ +/** @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(), policy: vi.fn() })) +vi.mock('@/lib/invitations/core', async (original) => ({ + ...(await original()), + lockInvitationForMutation: mocks.lock, +})) + +vi.mock('@/lib/invitations/resend-policy', () => ({ lockInvitationResendPolicy: mocks.policy })) + +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { prepareInvitationResend, revertInvitationResend } from '@/lib/invitations/send' + +const revision = new Date('2026-01-01') +const input = { + invitationId: 'inv', + actorUserId: 'actor', + expectedOrganizationId: 'org', + expectedUpdatedAt: revision, + currentToken: 'original-token', +} +beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + 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 preparation and compensation', () => { + 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', + 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, + }) + expect(mocks.policy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'inv' }), + 'actor', + 'org' + ) + 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.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(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' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects demotion and expiration before delivery', async () => { + mocks.policy.mockRejectedValueOnce( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) + await expect(prepareInvitationResend(input)).rejects.toMatchObject({ code: 'forbidden' }) + 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() + }) + + 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.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..1f8cc821802 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -20,10 +20,13 @@ 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 { 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' @@ -271,8 +274,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 +282,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 }) } @@ -681,38 +692,113 @@ 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 -}): Promise { - const [row] = await db - .update(invitation) - .set({ - expiresAt: params.nextExpiresAt, - updatedAt: new Date(), - ...(params.nextToken ? { token: params.nextToken } : {}), + currentToken: string + expectedOrganizationId?: string + expectedUpdatedAt: Date + actorUserId: string +}): Promise { + return db.transaction(async (tx) => { + const current = await lockInvitationForMutation(tx, params.invitationId, { + lockCurrentGrantWorkspaces: true, }) - .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`) - } + if ( + !current || + (params.expectedOrganizationId !== undefined && + current.organizationId !== params.expectedOrganizationId) + ) + throw new OrchestrationError('not_found', 'Invitation not found') + /** 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') + + 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), + 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 changed before it could be resent. Refresh before resending.' + ) + 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/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..2817d69a72c --- /dev/null +++ b/apps/sim/lib/organizations/application/members.ts @@ -0,0 +1,81 @@ +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 }) => ({ + 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..356453e0fe7 --- /dev/null +++ b/apps/sim/lib/organizations/application/use-cases.test.ts @@ -0,0 +1,344 @@ +/** @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('preserves audit on a successful same-role request', async () => { + queueTableRows(member, [{ role: 'admin' }]) + 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( + 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..f073507180e --- /dev/null +++ b/apps/sim/lib/organizations/member-manager.test.ts @@ -0,0 +1,116 @@ +/** @vitest-environment node */ +import { member, user } 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', 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 new file mode 100644 index 00000000000..7673bc22a48 --- /dev/null +++ b/apps/sim/lib/organizations/member-manager.ts @@ -0,0 +1,120 @@ +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 === 'User is an organization member' + ? 'conflict' + : 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, + onError: 'throw', + }) + 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 new file mode 100644 index 00000000000..4758fa97831 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/authorized-permission-group-use-case.ts @@ -0,0 +1,81 @@ +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) || + error.detailCode === 'ORGANIZATION_ADMIN_REQUIRED') + ) + 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..e4ac75bb96d --- /dev/null +++ b/apps/sim/lib/permission-groups/application/use-cases.ts @@ -0,0 +1,203 @@ +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, + type PermissionGroupMemberTarget, + 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 & PermissionGroupMemberTarget }) => + removePermissionGroupMemberRecord(input.organizationId, input.groupId, input), + 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, + added: result.added, + addedUserIds: result.addedUserIds, + ...(result.added > result.addedUserIds.length && { addedUserIdsTruncated: true }), + 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..2398ac7e92d --- /dev/null +++ b/apps/sim/lib/permission-groups/group-manager.test.ts @@ -0,0 +1,173 @@ +/** @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/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, + 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('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() + 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..3c492e42f09 --- /dev/null +++ b/apps/sim/lib/permission-groups/group-manager.ts @@ -0,0 +1,242 @@ +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 { withPermissionGroupMutation } from '@/lib/permission-groups/mutation' +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 withPermissionGroupMutation(organizationId, async (tx) => { + 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 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?.length) + 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 withPermissionGroupMutation(organizationId, async (tx) => { + 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..3232eacfd6d --- /dev/null +++ b/apps/sim/lib/permission-groups/member-manager.test.ts @@ -0,0 +1,222 @@ +/** @vitest-environment node */ +import { member, permissionGroupMember } from '@sim/db/schema' +import { dbChainMockFns, hasMockCondition, 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/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 })) +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.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') + ).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.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' }]) + 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('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: 1000 }, (_, index) => ({ userId: `member-${index}` })) + ) + queueTableRows(member, [{ userId: 'member-last' }]) + mocks.scopeConflicts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ userName: 'Member', conflictingGroupName: 'Other' }]) + await expect( + bulkAddPermissionGroupMemberRecords( + 'org-1', + 'group-1', + { addAllOrganizationMembers: true }, + 'admin-1' + ) + ).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 }]) + mocks.allConflict.mockResolvedValue({ + conflictingGroupName: 'Other', + workspaceName: 'Engineering', + }) + await expect( + 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', { 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', { 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 new file mode 100644 index 00000000000..3ce9401b534 --- /dev/null +++ b/apps/sim/lib/permission-groups/member-manager.ts @@ -0,0 +1,214 @@ +import { member, permissionGroupMember, user } from '@sim/db/schema' +import { chunkArray } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { and, asc, count, eq, gt, 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 { withPermissionGroupMutation } from '@/lib/permission-groups/mutation' +import { getGroupWorkspaces } from '@/lib/permission-groups/repository' + +export async function addPermissionGroupMemberRecord( + organizationId: string, + groupId: string, + userId: string, + actorUserId: string +) { + return withPermissionGroupMutation(organizationId, async (tx) => { + 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 type PermissionGroupMemberTarget = { memberId: string } | { userId: string } + +export async function removePermissionGroupMemberRecord( + organizationId: string, + groupId: string, + target: PermissionGroupMemberTarget +) { + return withPermissionGroupMutation(organizationId, async (tx) => { + 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( + 'memberId' in target + ? eq(permissionGroupMember.id, target.memberId) + : eq(permissionGroupMember.userId, target.userId), + 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, assignment.id), + 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 +) { + 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) + 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 + ) + 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/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/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) { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c3cde4daf57..dc6632172ed 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,5 +1,17 @@ import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' +const ORGANIZATION_FLAG = { + organizationId: { name: 'organization', describe: 'Organization identifier' }, +} as const +const PERMISSION_GROUP_MEMBER_FLAGS = { + ...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' @@ -934,6 +946,133 @@ 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: 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: ORGANIZATION_FLAG, + }, + createPermissionGroup: { + command: 'permission-groups create', + pathFlags: ORGANIZATION_FLAG, + flags: DEFAULT_FLAG, + }, + updatePermissionGroup: { + command: 'permission-groups update', + pathFlags: ORGANIZATION_FLAG, + flags: DEFAULT_FLAG, + }, + deletePermissionGroup: { + command: 'permission-groups delete', + 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: 'user', path: 'userId' }, + { header: 'name', path: 'userName' }, + { header: 'email', path: 'userEmail' }, + ], + }, + addPermissionGroupMember: { + command: 'permission-groups members add', + pathFlags: PERMISSION_GROUP_MEMBER_FLAGS, + flags: { userId: { name: 'user' } }, + }, + 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, + 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: [ { header: 'id' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index dc8616b41b2..fa2639292b7 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,153 @@ 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 +} + +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 +3186,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 +4869,119 @@ 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 + 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 +7083,211 @@ 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 + 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/sandboxes` */ export type ListSandboxesQuery = { workspaceId: string @@ -8760,6 +9289,41 @@ export type RelocateWorkflowFolderResponse = { data: RelocateWorkflowFolderResponseRef0 } +/** `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 + userId: string +} + +export type RemovePermissionGroupMemberQuery = Record + +type RemovePermissionGroupMemberResponseRef0 = { + userId: string + deleted: true +} + +export type RemovePermissionGroupMemberResponse = { + data: RemovePermissionGroupMemberResponseRef0 +} + /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -9033,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 @@ -9382,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 @@ -10303,25 +10910,169 @@ export type UpdateMcpServerResponse = { data: UpdateMcpServerResponseRef0 } -/** `PATCH /api/v2/tables/[tableId]/rows` */ -export type UpdateRowsByFilterParams = { - tableId: string +/** `PATCH /api/v2/organizations/[organizationId]/members/[userId]` */ +export type UpdateOrganizationMemberParams = { + organizationId: string + userId: string } -export type UpdateRowsByFilterQuery = Record +export type UpdateOrganizationMemberQuery = Record -type UpdateRowsByFilterBodyRef0 = - | { - all: Array< - | UpdateRowsByFilterBodyRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' +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 + 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 +} + +export type UpdateRowsByFilterQuery = Record + +type UpdateRowsByFilterBodyRef0 = + | { + all: Array< + | UpdateRowsByFilterBodyRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' | 'lte' | 'in' | 'nin' @@ -11227,6 +11978,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 +12105,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 in bounded batches within one transaction. Cannot be combined with userIds.', + }, + }, + }, bulkDeleteFiles: { method: 'POST', path: '/api/v2/files/bulk-delete', @@ -12101,6 +12891,57 @@ 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', + 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: + 'Permission restrictions to set. Omitted keys use the default permission configuration.', + }, + isDefault: { + kind: 'boolean', + describe: + 'Whether the group is the organization default. Only one group can be the default.', + }, + workspaceIds: { + kind: 'array', + describe: + 'Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.', + }, + }, + }, createSandbox: { method: 'POST', path: '/api/v2/sandboxes', @@ -12678,6 +13519,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 +14337,39 @@ 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]', + 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 +15887,241 @@ export const V2_OPERATIONS = { }, }, }, + listOrganizationInvitations: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + 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: ['email', 'createdAt'] as const, + default: 'createdAt', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum invitations 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.', + }, + }, + }, + listOrganizationMembers: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/members', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'List Organization Members', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against member name or email.', + }, + sortBy: { + kind: 'enum', + 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: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum 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.', + }, + }, + }, + listOrganizations: { + method: 'GET', + 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 identifier.' }, + responseMode: 'json', + summary: 'List Organization 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 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', + 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 +17450,31 @@ 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/[userId]', + pathParams: ['organizationId', 'groupId', 'userId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the permission groups.', + groupId: 'Permission group identifier.', + userId: 'User identifier of the member to remove.', + }, + responseMode: 'json', + summary: 'Remove Permission Group Member', + workspaceKeyUnsupported: true, + }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', @@ -16429,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', @@ -16569,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', @@ -17222,6 +18392,61 @@ 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]', + 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 or an empty string 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..d243006041e 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1026,6 +1026,13 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'createOrganizationInvitation', + 'resendOrganizationInvitation', + 'updateOrganizationMember', + '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..7a7e9ff20a6 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -127,6 +127,190 @@ 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('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( + [ + 'permission-groups', + 'members', + 'add', + '--organization', + 'org-1', + '--group', + 'group-1', + '--user', + '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.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/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..555aa921ac1 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -20,6 +20,11 @@ const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listChatDeployments', 'listCredentials', 'listCustomTools', + 'listPermissionGroups', + 'listPermissionGroupMembers', + 'listOrganizations', + 'listOrganizationMembers', + 'listOrganizationWorkspaces', 'listFiles', 'listKnowledgeBases', 'listKnowledgeConnectors', @@ -181,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 8bcd21afa02..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', 51], + ['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(247) + expect(totalOperations).toBe(267) }) it('documents mixed workflow execution and resume responses', () => { From 43c9f6c97a1049365405348ceea5fc46c6dffa4a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 21 Sep 2026 13:06:31 -0700 Subject: [PATCH 2/3] chore(ci): give the Trigger.dev upload job room for a slow image push (#8115) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c110d101e7..ab57ee711e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,7 +231,7 @@ jobs: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 30 outputs: version: ${{ steps.deploy.outputs.deploymentVersion }} environment: ${{ steps.target.outputs.environment }} From 2645f1cdcc3a26308035c5636b449f61f7f92e87 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 21 Sep 2026 13:58:05 -0700 Subject: [PATCH 3/3] chore(trigger): upgrade Trigger.dev to 4.5.16 so deploy images build without deleting node_modules (#8118) --- .github/workflows/ci.yml | 4 ++-- apps/sim/package.json | 6 +++--- bun.lock | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab57ee711e2..92b44041af3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,7 +289,7 @@ jobs: if [ -n "$TRIGGER_BRANCH" ]; then TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") fi - bunx trigger.dev@4.5.12 deploy "${TARGET_ARGS[@]}" --skip-promotion + bunx trigger.dev@4.5.16 deploy "${TARGET_ARGS[@]}" --skip-promotion - name: Validate deployment version output env: @@ -643,7 +643,7 @@ jobs: if [ -n "$TRIGGER_BRANCH" ]; then TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") fi - bunx trigger.dev@4.5.12 promote "$VERSION" "${TARGET_ARGS[@]}" + bunx trigger.dev@4.5.16 promote "$VERSION" "${TARGET_ARGS[@]}" # Build ARM64 images for GHCR (main branch only, runs in parallel with # tests). Pushes only the immutable sha tag — latest-arm64/version-arm64 diff --git a/apps/sim/package.json b/apps/sim/package.json index aab01234f88..6491c0e02b2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -150,8 +150,8 @@ "@tiptap/starter-kit": "3.30.5", "@tiptap/suggestion": "3.30.5", "@tiptap/y-tiptap": "3.0.7", - "@trigger.dev/core": "4.5.12", - "@trigger.dev/sdk": "4.5.12", + "@trigger.dev/core": "4.5.16", + "@trigger.dev/sdk": "4.5.16", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -265,7 +265,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.5.12", + "@trigger.dev/build": "4.5.16", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/heic-convert": "2.1.1", diff --git a/bun.lock b/bun.lock index d3a4a9f48fd..20348eac192 100644 --- a/bun.lock +++ b/bun.lock @@ -273,8 +273,8 @@ "@tiptap/starter-kit": "3.30.5", "@tiptap/suggestion": "3.30.5", "@tiptap/y-tiptap": "3.0.7", - "@trigger.dev/core": "4.5.12", - "@trigger.dev/sdk": "4.5.12", + "@trigger.dev/core": "4.5.16", + "@trigger.dev/sdk": "4.5.16", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -388,7 +388,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.5.12", + "@trigger.dev/build": "4.5.16", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/heic-convert": "2.1.1", @@ -2145,11 +2145,11 @@ "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], - "@trigger.dev/build": ["@trigger.dev/build@4.5.12", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.5.12", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" }, "peerDependencies": { "@typescript/typescript6": "^6.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@typescript/typescript6", "typescript"] }, "sha512-6GFSwntGLv+d0Ix7US2jM/JnL7GzjIMC21KkMneGet9dJTaQCQg9WPICx5V+UKIBQ7AcPBeOPioAmw+qq3prrw=="], + "@trigger.dev/build": ["@trigger.dev/build@4.5.16", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.5.16", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" }, "peerDependencies": { "@typescript/typescript6": "^6.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@typescript/typescript6", "typescript"] }, "sha512-iy/KJylq3BdNlDt3++HhkncPRDHzkuNOnK1SxhkTtRc1Hj+kV5zNtaBuTxrxUCyS1hwOLLbQl7rBaSPoGzSyUg=="], - "@trigger.dev/core": ["@trigger.dev/core@4.5.12", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-http": "0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/exporter-trace-otlp-http": "0.218.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@s2-dev/streamstore": "0.25.0", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.18", "prom-client": "^15.1.0", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-validation-error": "^1.5.0" } }, "sha512-MvDJg01vELqtc2d+fzcqwWlLN8MhBHt4xKPQQqur/Xlz5dzuD2YHbZGdursAJg8PPXWgNnelipr5cUh0GYxaqQ=="], + "@trigger.dev/core": ["@trigger.dev/core@4.5.16", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-http": "0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/exporter-trace-otlp-http": "0.218.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@s2-dev/streamstore": "0.25.0", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.18", "prom-client": "^15.1.0", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-validation-error": "^1.5.0" } }, "sha512-7Gcv8rl+DnPxvguiWSp+KUeA7HVM9Xp46YYEeBGgViahL/Ml3WIv2hXXEiHrP8L3aM9C9YtStmmRmz5yOzW4fw=="], - "@trigger.dev/sdk": ["@trigger.dev/sdk@4.5.12", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.5.12", "uncrypto": "^0.1.3" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "ai", "react"] }, "sha512-kSbLzD50v9VmczK+GD97Iwl8qYgd8d42UbGeH3cBbJMoQSR0rNtSZ8WhUpcj8qunbPJHU5VM3usLDA3Bk6qdkg=="], + "@trigger.dev/sdk": ["@trigger.dev/sdk@4.5.16", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.5.16", "uncrypto": "^0.1.3" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "ai", "react"] }, "sha512-YjHz3Zv/yyLAS6tqFZMTVD2yTP6FopbPaLgtECLUqYx/pV2LQftgs7XI5o6NvtJxaDDLEz2wHqegCte1Ww2lKg=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A=="],